# 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.
