Files
docs/apps/backend-content-crud.md

674 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.