feat: migrate to VitePress from monorepo docs, add test-contour section

This commit is contained in:
sova-bootstrap
2026-05-28 12:29:31 +03:00
parent e90dfe1bd4
commit e3e438df68
76 changed files with 11998 additions and 60 deletions
+673
View File
@@ -0,0 +1,673 @@
# Backend: CRUD для контентных сущностей
Эта страница объясняет рефакторинг CRUD-эндпоинтов для контентных сущностей backend API. Цель изменений - чтобы backend-разработчик быстро понял, где находится код, как проходит запрос, как добавить новый похожий ресурс и почему пагинация сделана через `Pagerfanta`.
## Задача (#27)
Нужно дать API для админ-панели контент-менеджеров ([UI](/apps/admin-panel-content-crud)), чтобы управлять контентом не напрямую через Bitrix, а через backend:
- новости;
- акции;
- заболевания;
- центры;
- статьи;
- услуги.
Основные требования:
- единый CRUD-подход для всех шести ресурсов;
- пагинация в стиле Symfony/проекта, без ручного `LIMIT/OFFSET + COUNT`;
- минимум дублирования в контроллерах и сервисах;
- запись только для пользователей с `ROLE_ADMIN`;
- сохранить существующую синхронизацию из SQL view/Bitrix.
## Ресурсы и маршруты
| Сущность | Контроллер | Базовый путь | Read group | Write group |
| --- | --- | --- | --- | --- |
| `News` | `NewsController` | `/news` | `news:read` | `news:write` |
| `Promo` | `PromoController` | `/promo` | `promo:read` | `promo:write` |
| `Disease` | `DiseaseController` | `/disease` | `disease:read` | `disease:write` |
| `MedicalCenter` | `MedicalCenterController` | `/medical-center` | `medical_center:read` | `medical_center:write` |
| `Article` | `ArticleController` | `/article` | `article:read` | `article:write` |
| `SiteService` | `SiteServiceController` | `/site-services` | `site_service:read` | `site_service:write` |
У всех ресурсов есть одинаковый набор CRUD-маршрутов:
| Метод | Путь | Доступ | Назначение |
| --- | --- | --- | --- |
| `GET` | `/{resource}/list` | публичный | список с пагинацией и фильтрами |
| `GET` | `/{resource}/{id}` | публичный | детальная карточка |
| `POST` | `/{resource}/create` | `ROLE_ADMIN` | создание |
| `PUT` | `/{resource}/{id}` | `ROLE_ADMIN` | обновление |
| `DELETE` | `/{resource}/{id}` | `ROLE_ADMIN` | удаление |
У `ArticleController` дополнительно есть `GET /article/alias/{alias}`.
## Общая схема
```mermaid
flowchart TD
Client[HTTP client / admin panel] --> Controller[Content Controller]
Controller -->|GET /list| Repository[Repository::createFilteredQueryBuilder]
Repository --> Filter[ContentFilterTrait]
Filter --> QueryBuilder[Doctrine QueryBuilder]
QueryBuilder --> Paginator[Pagination\\Paginator]
Paginator --> Pagerfanta[Pagerfanta + QueryAdapter]
Pagerfanta --> JsonList[JSON: data + pagination/meta]
Controller -->|POST/PUT/DELETE/GET detail| CrudResponder[CrudResponder]
CrudResponder --> Serializer[Symfony Serializer groups]
CrudResponder --> Validator[Symfony Validator]
CrudResponder --> EntityManager[Doctrine EntityManager]
EntityManager --> Db[(PostgreSQL)]
Commands[Upload*Command] --> SyncService[*CrudService::syncFromView*]
SyncService --> Db
```
## Ключевой принцип
Контроллер не должен знать, как парсить JSON, как делать `persist/flush`, как форматировать ошибки валидации и как считать страницы.
Контроллер отвечает только за HTTP-маршрут, выбор entity-класса, serializer groups и вызов общего сервиса.
## Тонкий контроллер
Пример `NewsController`:
```php
#[Route('/news')]
final class NewsController extends AbstractController
{
private const READ_GROUPS = ['news:read'];
private const WRITE_GROUPS = ['news:write'];
public function __construct(
private readonly CrudResponder $crud,
private readonly Paginator $paginator,
) {
}
#[Route('/list', name: 'news_list', methods: ['GET'])]
public function list(Request $request, NewsRepository $repository): JsonResponse
{
$qb = $repository->createFilteredQueryBuilder(
ContentFilterDto::fromRequest($request, defaultActive: true),
);
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
]);
}
#[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\\d+'])]
public function show(News $news): JsonResponse
{
return $this->crud->read($news, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/create', name: 'news_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\\d+'])]
public function update(Request $request, News $news): JsonResponse
{
return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS);
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\\d+'])]
public function delete(News $news): JsonResponse
{
return $this->crud->delete($news);
}
}
```
Остальные пять контроллеров устроены так же. Отличаются только:
- базовый `#[Route]`;
- entity-класс;
- repository-класс;
- serializer groups.
## Пагинация
Пагинация вынесена в `App\Service\Pagination\Paginator`.
Причина: в проекте уже есть похожий подход в `PriceListController`: `QueryAdapter` + `Pagerfanta`. Поэтому для новых списков не нужно писать свою пагинацию и отдельный `COUNT`-запрос.
Сервис принимает готовый Doctrine `QueryBuilder` и `Request`:
```php
public function paginate(
QueryBuilder $qb,
Request $request,
int $defaultPerPage = self::DEFAULT_PER_PAGE,
int $maxPerPage = self::MAX_PER_PAGE,
): array
```
Он читает:
- `page` - номер страницы, минимум `1`;
- `perPage` - размер страницы, минимум `1`, максимум `500`.
Формат ответа для `news`, `promo`, `disease`, `medical-center`, `site-services`:
```json
{
"data": [],
"pagination": {
"total": 42,
"count": 10,
"per_page": 10,
"current_page": 1,
"total_pages": 5,
"has_previous_page": false,
"has_next_page": true
}
}
```
Для `article` сохранён старый API-контракт фронтенда: параметр размера страницы называется `limit`, а метаданные лежат в ключе `meta`.
```json
{
"data": [],
"meta": {
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}
```
Это сделано через отдельный метод `Paginator::paginateWithLegacyMeta()`, чтобы не ломать клиентов, которые читают `response.data.meta.total`.
Важно: `Paginator` не знает ничего про конкретную сущность. Он работает с любым `QueryBuilder`.
## Фильтрация списков
Каждый repository имеет метод:
```php
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
```
Пример `NewsRepository`:
```php
class NewsRepository extends ServiceEntityRepository
{
use ContentFilterTrait;
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
$this->applyCommonFilters($qb, 'n', $filters);
return $qb;
}
}
```
Общие фильтры лежат в `App\Repository\ContentFilterTrait`. Он подключается в нужные Doctrine-репозитории и не требует статических helper-вызовов.
HTTP query-параметры не передаются в repository сырым массивом. Контроллер сначала мапит `Request` в `ContentFilterDto`:
```php
// По умолчанию без фильтра по active (как /disease, /article):
ContentFilterDto::fromRequest($request)
// Легаси: если параметр active не передан, считать active = true (news, promo, medical-center, site-services):
ContentFilterDto::fromRequest($request, defaultActive: true)
```
Так слой БД получает типизированный объект с уже разобранными значениями (`?int`, `?bool`, `?string`), а не `array<string, mixed>` из HTTP.
Нежелательные формы query вроде `?regionId[]=1` или `?active[]=1` отдают Symfony в `get()` как массив: `ContentFilterDto` обрабатывает только scalar-значения для числовых и булевых полей, такие случаи трактуются как «фильтр не задан» и **не** вызывают 500 (TypeError).
Поддерживаемые query-параметры:
| Параметр | Тип | Что делает |
| --- | --- | --- |
| `regionId` / `region_id` | integer | фильтр по региону |
| `active` | boolean | фильтр активности; **для `/news`, `/promo`, `/medical-center`, `/site-services/list` при отсутствии параметра применяется `active = true`** (легаси). Для `/disease` и `/article` без параметра фильтр по `active` не накладывается |
| `alias` | string | точное совпадение alias |
| `search` / `q` | string | поиск по `LOWER(name) LIKE ...` |
Для больших таблиц под `search` нужен функциональный индекс PostgreSQL по `LOWER(name)`. Если решите заменить это на `ILIKE`, потребуется отдельная Doctrine DQL-функция или переход на native SQL для этого фильтра.
Поле, по которому ищем, параметризовано в трейте: `applyCommonFilters($qb, $alias, $filters, $searchField = 'name')`. Все шесть текущих сущностей имеют свойство `$name`, поэтому в репозиториях оно передаётся неявно по умолчанию. Если новая сущность хранит основное название в другом поле (например, `title`), достаточно явно прокинуть его именем в трейт.
### Naming strategy
В `config/packages/serializer.yaml` сознательно не настроен `NameConverter`. JSON-ключи запросов и ответов используют **camelCase** ровно так, как названы свойства сущности (`regionId`, `previewPicture`, `updateAt`, …). Если клиент пришлёт snake_case (`region_id`, `preview_picture`), Symfony Serializer молча проигнорирует такие ключи, и поля не запишутся в сущность - это и есть причина, по которой старые `*CrudService` поддерживали оба формата вручную через `array_key_exists`. Теперь поддержка обоих форматов сознательно убрана: клиент должен присылать консистентный camelCase, иначе будет тихая потеря данных.
Пример запроса:
```bash
curl 'http://localhost:8081/news/list?page=1&perPage=20&regionId=91&active=true&search=акция'
```
## Create / Update / Delete
Общая логика записи вынесена в `App\Service\Crud\CrudResponder`.
Что делает `CrudResponder`:
- проверяет, что body - JSON-объект (ловит и нативный `\JsonException`, и `Symfony\...\HttpFoundation\Exception\JsonException`);
- денормализует payload через `DenormalizerInterface::denormalize($array, $class, null, [...])`, без дополнительного `json_encode/deserialize` round-trip;
- использует write-группу сущности, например `news:write`;
- удаляет `id` из payload перед денормализацией для create/update;
- валидирует entity через Symfony Validator и при ошибке отдаёт **HTTP 400** + сериализованный `ConstraintViolationList` (формат Symfony по умолчанию, RFC 7807 с ключом `violations`) - тот же формат, что отдавали старые `*CrudService`-контроллеры;
- делает `persist/flush` для create;
- делает `flush` для update;
- делает `remove/flush` для delete; при ошибке БД (например, foreign key constraint) ловит `Doctrine\DBAL\Exception` и возвращает `HTTP 500` + `{error, message}` - сохраняем легаси-контракт старого `ArticleController::delete`;
- сериализует ответ через read-группу, например `news:read`.
### Контракты ответов на ошибки
| Сценарий | HTTP | Тело |
| --- | --- | --- |
| Тело не JSON-объект | 400 | `{"error": "Ожидается JSON-объект в теле запроса"}` |
| Денормализация упала (например, ждали int, прислали object) | 400 | `{"error": "Ошибка десериализации: ..."}` |
| Symfony Validator нашёл нарушения | 400 | сериализованный `ConstraintViolationList` (`type`, `title`, `detail`, `violations: [{propertyPath, title, template, parameters}]`) |
| Удаление упало в БД (FK / not null / unique) | 500 | `{"error": "Ошибка при удалении записи", "message": "..."}` |
| Успешный delete | 204 | пустое тело |
Create:
```php
return $this->crud->create($request, News::class, ['news:write'], ['news:read']);
```
Update:
```php
return $this->crud->update($request, $news, ['news:write'], ['news:read']);
```
Delete:
```php
return $this->crud->delete($news);
```
## Почему не ручной updateEntity
Раньше в сервисах были большие методы вида:
```php
if (array_key_exists('name', $data)) {
$news->setName($data['name']);
}
if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
$v = $data['regionId'] ?? $data['region_id'];
$news->setRegionId($v === null || $v === '' ? null : (int) $v);
}
```
Такой подход плохо масштабируется:
- одинаковые проверки копируются между сущностями;
- легко забыть поле;
- легко по-разному обработать один и тот же тип;
- controller/service превращается в ручной mapper;
- сложнее держать безопасность записи через allowlist.
Теперь allowlist полей задаётся serializer groups прямо в entity:
```php
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['news:read', 'news:write'])]
private ?string $name = null;
#[ORM\GeneratedValue(strategy: "IDENTITY")]
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['news:read'])]
private ?int $id = null;
```
Если поле не входит в `*:write`, клиент не может изменить его через `PUT`.
По текущей проверке write-группы у контентных сущностей содержат только scalar/json поля. ORM-связей (`OneToMany`, `ManyToMany` и т.п.) в этих шести сущностях нет. Если такие связи появятся позже, их нельзя автоматически добавлять в `*:write`: сначала нужно отдельно решить, можно ли менять эту связь через публичный/admin JSON API.
## Особенность id
Для публичного/admin CRUD `id` должен генерироваться приложением/БД, а не приходить от клиента. У контентных сущностей настроен `GeneratedValue(strategy: "IDENTITY")`, а миграция добавляет sequence/default для таблиц, где id раньше был assigned.
Поэтому поведение такое:
- на `POST /create` `CrudResponder` по умолчанию удаляет `id` из payload до десериализации;
- на `PUT /{id}` `CrudResponder` всегда удаляет `id` из payload до десериализации;
- даже если кто-то случайно добавит `id` в `*:write` group, первичный ключ не будет перезаписан через HTTP CRUD.
Это защищает от ситуации, когда клиент обновляет `/news/10`, но присылает `"id": 999` и случайно/намеренно меняет первичный ключ.
Пример create:
```json
{
"name": "Новость из админки",
"active": true,
"regionId": 91,
"alias": "admin-news",
"anons": "Короткий анонс",
"content": "Полный текст"
}
```
Пример update:
```json
{
"id": 123,
"name": "Новое название",
"active": false
}
```
В этом update поле `id` будет проигнорировано.
Синхронизация из Bitrix/view остаётся отдельным SQL-процессом: `syncFromView*` по-прежнему вставляет исторические id напрямую через `INSERT ... SELECT ... ON CONFLICT`. PostgreSQL sequence настроена так, чтобы новые id брались выше текущего `MAX(id)`.
## update_at
Поле `updateAt` больше не входит в `*:write`, поэтому фронтенд не управляет датой обновления напрямую.
Для автоматического заполнения используется Doctrine lifecycle callback:
```php
#[ORM\HasLifecycleCallbacks]
class News
{
use UpdateTimestampTrait;
}
```
`UpdateTimestampTrait`:
- на `PrePersist` ставит текущую метку времени (`\DateTimeImmutable`), если `updateAt` ещё пустой;
- на `PreUpdate` обновляет `updateAt` новой `DateTimeImmutable`;
- в трейте объявлен `@property \DateTimeInterface|null $updateAt` для статического анализа (само поле остаётся в сущности с `#[Groups]`).
Импорт из Bitrix/view продолжает писать `update_at` напрямую SQL-ом и не зависит от HTTP CRUD lifecycle callbacks.
## Синхронизация из Bitrix/view
У сервисов `NewsCrudService`, `PromoCrudService`, `DiseaseCrudService`, `MedicalCenterCrudService`, `SiteServiceCrudService` теперь оставлена только ответственность за импорт:
- `NewsCrudService::syncFromViewNews()`;
- `PromoCrudService::syncFromViewPromo()`;
- `DiseaseCrudService::syncFromViewDisease()`;
- `MedicalCenterCrudService::syncFromViewCenters()`;
- `SiteServiceCrudService::syncFromViewServices()`.
Эти методы используются командами:
- `UploadNewsCommand`;
- `UploadPromoCommand`;
- `UploadDiseasesCommand`;
- `UploadMedicalCentersCommand`;
- `UploadSiteServicesCommand`.
Идея разделения:
- HTTP CRUD - это controller + `CrudResponder` + `Paginator` + repository;
- импорт из внешних view - это sync service + console command.
Так backend-разработчику проще понять, где менять API-поведение, а где менять импорт.
## Swagger
Контентные маршруты должны попадать в Swagger через `apps/backend/config/packages/nelmio_api_doc.yaml`.
Path patterns:
```yaml
path_patterns: [
'^/news($|/)',
'^/promo($|/)',
'^/disease($|/)',
'^/medical-center($|/)',
'^/article($|/)',
'^/site-services($|/)'
]
```
Swagger UI локально:
```text
http://localhost:8081/docs
```
OpenAPI JSON:
```text
http://localhost:8081/api/doc.json
```
## Как добавить новый похожий CRUD-ресурс
1. Проверить entity и serializer groups:
```php
#[Groups(['example:read', 'example:write'])]
private ?string $name = null;
#[Groups(['example:read'])]
private ?\DateTimeInterface $updateAt = null;
#[Groups(['example:read'])]
private ?int $id = null;
```
2. Если у сущности есть `updateAt`, подключить lifecycle callbacks:
```php
#[ORM\HasLifecycleCallbacks]
class Example
{
use UpdateTimestampTrait;
}
```
3. В repository добавить `createFilteredQueryBuilder`:
```php
public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
$qb = $this->createQueryBuilder('e')->orderBy('e.id', 'DESC');
$this->applyCommonFilters($qb, 'e', $filters);
return $qb;
}
```
4. В controller подключить `CrudResponder` и `Paginator`.
5. Для list использовать (для легаси-поведения «только активные», если клиент не передал `active`, см. второй аргумент — как у `/news`, `/promo`, `/medical-center`, `/site-services`):
```php
$qb = $repository->createFilteredQueryBuilder(
ContentFilterDto::fromRequest($request, defaultActive: true),
);
return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
'groups' => self::READ_GROUPS,
]);
```
Если фильтр по активности по умолчанию не нужен (`/disease`, `/article`), передайте только `$request`.
6. Для write-операций использовать:
```php
return $this->crud->create($request, Example::class, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->update($request, $example, self::WRITE_GROUPS, self::READ_GROUPS);
return $this->crud->delete($example);
```
7. Добавить `#[OA\RequestBody(... Model(... groups: self::WRITE_GROUPS))]` на `create()` и `update()`.
8. Добавить path pattern в `nelmio_api_doc.yaml`.
9. Проверить маршруты:
```bash
php bin/console debug:router --env=dev
```
## Проверка после изменений
Внутри контейнера backend:
```bash
cd /var/www/backend
composer dump-autoload -o
php bin/console cache:clear --env=dev --no-warmup
php bin/console cache:warmup --env=dev
php bin/console debug:router --env=dev
```
Публичная проверка списка:
```bash
curl 'http://localhost:8081/news/list?page=1&perPage=2'
```
Ожидаемый минимум:
```json
{
"data": [],
"pagination": {
"total": 0,
"count": 0,
"per_page": 2,
"current_page": 1,
"total_pages": 1,
"has_previous_page": false,
"has_next_page": false
}
}
```
Проверка legacy-контракта статей:
```bash
curl 'http://localhost:8081/article/list?page=1&limit=2'
```
Ожидаемые верхнеуровневые ключи:
```json
{
"data": [],
"meta": {
"total": 0,
"page": 1,
"limit": 2,
"totalPages": 1
}
}
```
Для `POST`, `PUT`, `DELETE` нужен JWT пользователя с ролью `ROLE_ADMIN`.
## Что не надо возвращать обратно
Не стоит возвращать:
- отдельные `getPaginatedList()` в каждый CRUD-service;
- ручные `findByFilters()` и `countByFilters()` для каждой сущности;
- `json_decode` в каждом controller;
- большие `updateEntity()` с десятками `array_key_exists`;
- ручное изменение `id` в `PUT`.
Эти вещи уже закрыты общими классами и serializer groups.
## Ветки Git и MR-файлы
Чтобы не смешивать архитектуру и шесть CRUD в одном MR, история разнесена по веткам. Ветка **`issues/27`** — интеграционная (всё сразу), **для новых MR не использовать**.
```text
origin/dev
└── feature/content-crud-architecture # общая инфраструктура (MR #1)
├── feature/content-crud-news # MR по ресурсу
├── feature/content-crud-promo
├── feature/content-crud-disease
├── feature/content-crud-medical-center
├── feature/content-crud-article
└── feature/content-crud-site-services
```
UI админки: [admin-panel-content-crud](/apps/admin-panel-content-crud) — ветка `issues/27-refactor` от `dev`.
### Файлы MR (корень монорепозитория)
Сгенерированы скриптом `scripts/generate-backend-mr.sh` (база `origin/dev`, не в git):
| MR | diff | HTML |
| --- | --- | --- |
| Архитектура | `mr-backend-content-crud-architecture.diff` | `MR/mr-backend-content-crud-architecture.html` |
| Новости | `mr-backend-content-crud-news.diff` | `MR/mr-backend-content-crud-news.html` |
| Промо | `mr-backend-content-crud-promo.diff` | `MR/mr-backend-content-crud-promo.html` |
| Заболевания | `mr-backend-content-crud-disease.diff` | `MR/mr-backend-content-crud-disease.html` |
| Медцентры | `mr-backend-content-crud-medical-center.diff` | `MR/mr-backend-content-crud-medical-center.html` |
| Статьи | `mr-backend-content-crud-article.diff` | `MR/mr-backend-content-crud-article.html` |
| Услуги сайта | `mr-backend-content-crud-site-services.diff` | `MR/mr-backend-content-crud-site-services.html` |
Пересборка: `./scripts/generate-backend-mr.sh` из корня репозитория.
### MR 1 — `feature/content-crud-architecture`
Показать разработчикам **общую доработку** (без контроллеров и без привязки к одной сущности):
| Файл | Назначение |
| --- | --- |
| `src/Dto/Content/ContentFilterDto.php` | query-параметры list (search, page, regionId, active) |
| `src/Service/Pagination/Paginator.php` | Pagerfanta, `pagination` / `meta` |
| `src/Repository/ContentFilterTrait.php` | фильтры в QueryBuilder |
| `migrations/Version20260515142000.php` | `IDENTITY` + `SEQUENCE` / `setval` для контент-таблиц |
| `src/Service/Crud/CrudResponder.php` | create/read/update/delete, валидация, denormalize |
| `src/Entity/Behavior/UpdateTimestampTrait.php` | `updateAt` на persist |
В полной ветке architecture также есть `config/validator/ContentCrud.yaml` и правки всех `Entity` — в **точечных MR по ресурсу** попадают изменения своей сущности (см. ниже).
### MR 2…7 — ветки по ресурсу
Частичная реализация **поверх архитектуры**: тонкий контроллер, репозиторий с `ContentFilterTrait`, `*CrudService` (sync из view), правки entity.
Пример **новостей** (`feature/content-crud-news`):
| Файл | Что в MR |
| --- | --- |
| `config/packages/nelmio_api_doc.yaml` | только строка `'^/news($|/)'` |
| `src/Controller/NewsController.php` | CRUD-маршруты |
| `src/Entity/News.php` | trait, groups, `GeneratedValue` |
| `src/Repository/NewsRepository.php` | list + фильтры |
| `src/Service/NewsCrudService.php` | sync из view |
| Ветка | Свои файлы (аналогично news) |
| --- | --- |
| `feature/content-crud-promo` | `PromoController`, `Promo.php`, `PromoRepository`, `PromoCrudService`, `'^/promo($|/)'` |
| `feature/content-crud-disease` | `DiseaseController`, `Disease.php`, `DiseaseRepository`, `DiseaseCrudService`, `'^/disease($|/)'` |
| `feature/content-crud-medical-center` | `MedicalCenterController`, `MedicalCenter.php`, `MedicalCenterRepository`, `MedicalCenterCrudService`, `'^/medical-center($|/)'` |
| `feature/content-crud-article` | `ArticleController`, `Article.php`, `ArticleRepository`, `'^/article($|/)'` (без отдельного `ArticleCrudService` в ветке) |
| `feature/content-crud-site-services` | `SiteServiceController`, `SiteService.php`, `SiteServiceRepository`, `SiteServiceCrudService`, `'^/site-services($|/)'` |
Порядок мержа: **architecture** → любые **resource** (параллельно). `issues/27` — только локальная интеграция, не для review.