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
+108
View File
@@ -0,0 +1,108 @@
---
title: Анонимная запись на приём через MIS (Backend)
---
# Сценарий 3.1: Анонимная запись (**Anonymous Reserve**)
## Бизнес-цель
Посетитель сайта может **записаться на приём**, указав контактные данные и выбранный слот, **без обязательной регистрации** в Laravel/Next-сенсах личного кабинета: запись фиксируется в **МИС (Инфоклиника)**.
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `POST /reservation/anonymous-reserve` | `InfoclinicaController::bookingAnonymous` |
Внутренне вызывается Messenger-сообщение **`GetAnonymousReserveRequestMessage`** (транспорт `sync` по `messenger.yaml`).
## Входной контракт (DTO)
`App\Dto\AnonymousReserveRequestDto` валидирует:
- ФИО, email, телефон в формате `+7(999)999-99-99`;
- `workDate` — 8 цифр `YYYYMMDD`;
- `time` — интервал `HH:MM-HH:MM`;
- `filial`, `schedident`, `rnum`, `specialist` (dcode), `accept`, `captcha` и т.д.
Метод `toArray()` формирует тело для MIS:
- поле `reserve`**JSON-строка** с деталями слота (`date`, `st`, `en`, `services`, `filial`, `timezone`, `schedident`, `rnum`, `dcode`).
Пример упрощённой структуры тела:
```json
{
"accept": true,
"fio": "Иванов Иван",
"captcha": "...",
"email": "user@example.com",
"phone": "+7(903)123-45-67",
"reserve": "{\"date\":\"20260520\",\"st\":\"10:00\",\"en\":\"10:30\", ... }"
}
```
## Пошаговый алгоритм
1. `InfoclinicaController::bookingAnonymous` десериализует JSON в `AnonymousReserveRequestDto`, валидирует.
2. `SpecialistService::createAnonymousReserve($dto)` создаёт `GetAnonymousReserveRequestMessage` и диспатчит через `MessageBusInterface` (sync).
3. `GetAnonymousReserveRequestMessageHandler::__invoke`:
- логирует старт;
- вызывает `InfoclinicaClientService::anonymousReserve($dto)`;
- выполняет `POST` на путь **`/api/reservation/anonymous-reserve`** MIS с `json_encode($dto->toArray())`;
- возвращает массив ответа `toArray()` HTTP-клиента Symfony;
- при `HttpExceptionInterface` — лог и массив с `status_code`, телом ответа MIS.
4. Контроллер делает `$this->json($reserve, $reserve['status_code'] ?? 200)`**если в ответе есть числовой `status_code`, он подставляется как HTTP-код ответа API**.
## Создание строки `Record` в PostgreSQL
В текущем дереве `apps/backend/src` **не найдено** кода `persist(new Record(...))` или использования `RecordRepository` в связке с этим эндпоинтом. Сущность `Record` и таблица описаны в модели данных, но **локальное сохранение факта записи вместе с анонимным reserve в этом HTTP-сценарии не реализовано** (или перенесено в другой сервис).
## Mermaid
```mermaid
sequenceDiagram
participant C as Клиент
participant IC as InfoclinicaController
participant SS as SpecialistService
participant BUS as MessageBus
participant H as GetAnonymousReserveRequestMessageHandler
participant CL as InfoclinicaClientService
participant MIS as Инфоклиника API
C->>IC: POST /reservation/anonymous-reserve
IC->>SS: createAnonymousReserve(dto)
SS->>BUS: dispatch(GetAnonymousReserveRequestMessage)
BUS->>H: __invoke
H->>CL: anonymousReserve(dto)
CL->>MIS: POST /api/reservation/anonymous-reserve
MIS-->>CL: JSON
CL-->>H: массив
H-->>IC: результат
IC-->>C: HTTP код из status_code или 200
```
## Внешние зависимости
| Система | Роль |
| --- | --- |
| Инфоклиника | создание записи |
| PostgreSQL | **не задействуется** для `Record` в этом сценарии (по текущему коду) |
## Обработка ошибок и edge cases
- **`InvalidArgumentException` в контроллере** — `400` с текстом валидации.
- **Ошибки десериализации JSON в DTO** — должны обрабатываться обработчиком исключений приложения (в контроллере явного try/catch на `ExceptionInterface` нет).
- **Ответ MIS с ошибкой**: может прийти как `200` с полем `status_code` внутри JSON — клиентский код фронта должен учитывать оба уровня (HTTP и вложенный).
## Ссылки на классы
- `apps/backend/src/Controller/InfoclinicaController.php`
- `apps/backend/src/Service/Specialist/SpecialistService.php`
- `apps/backend/src/Message/GetAnonymousReserveRequestMessage.php`
- `apps/backend/src/MessageHandler/GetAnonymousReserveRequestMessageHandler.php`
- `apps/backend/src/Service/Client/InfoclinicaClientService.php`
- `apps/backend/src/Dto/AnonymousReserveRequestDto.php`
См. [sms-record.md](./sms-record.md), [backend-ddd.md](../backend-ddd.md).
+99
View File
@@ -0,0 +1,99 @@
---
title: Авторизация по UID и pcode (Backend)
---
# Сценарий 1.2: Авторизация по **UID** и **pcode**
## Бизнес-цель
Часть пользователей приходит из медицинской системы (**Инфоклиника / MIS**): у пациента уже есть **числовой идентификатор** в сторонней системе. Сайт должен позволить:
- связать этот **UID** с учётной записью в API (`App\Entity\User::uid`);
- войти **по email+паролю**, если пользователь уже создан с тем же `uid`;
- войти **по pcode + дате рождения**, если личный кабинет строится на «медицинском» коде без ввода email на первом шаге.
## Что такое `uid` и `pcode` в коде
| Термин в API | Где в коде | Смысл |
| --- | --- | --- |
| `uid` | поле `User::$uid`, уникальное `int` | Идентификатор пациента/пользователя из внешней системы (в т.ч. MIS). |
| `pcode` в `POST /user/auth-by-pcode` | мапится в `UserUidAuthDto::$uid` | Тот же **числовой идентификатор**, что и `uid`; название «pcode» — контракт фронта/legacy. |
Откуда берутся значения **на практике**: из МИС/личного кабинета после идентификации пациента (конкретный HTTP-запрос к Инфоклинике **в этом приложении** для «получить pcode» в сценарии логина не вызывается — клиент передаёт уже известные `uid`/`pcode` и дату рождения).
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `POST /user/auth` | `UserController::auth` |
| HTTP | `POST /user/auth-by-pcode` | `UserController::authByPcode` |
## Пошаговый алгоритм — `POST /user/auth`
1. Тело: `uid`, `regionId`, `email`, `password`, опционально `bdate``UserAuthDto`, валидация.
2. `AuthenticationService::jwtAuth(dto)`:
- `UserRepository::findOneBy(['uid' => $dto->uid])`;
- если пользователь есть — проверка пароля **введённого** `password` через `passwordHasher`;
- если пользователя **нет** — возвращается `user: null` (флаг пароля не применяется).
3. Если пароль не совпал при существующем пользователе — `400`, как при обычном логине.
4. Если пользователя не было — `RegistrationService::create(dto)`:
- `setEmail(md5($dto->email))` (в БД снова **не** хранится открытый email);
- `setUid`, `setRegionId`, роли `ROLE_USER`, `birthDate` из `bdate` (формат `Ymd`), хэш пароля.
5. `updateLoggedIn`, `flush`, выдача JWT через `JWTTokenManagerInterface::create`.
## Пошаговый алгоритм — `POST /user/auth-by-pcode`
1. Тело: `pcode` (кладётся в `UserUidAuthDto::$uid`), `birthDate` или `bdate`.
2. Валидация DTO, разбор даты (`Ymd` или `Y-m-d`).
3. `UserRepository::findOneByUidAndBirthDate($uid, $birthDate)` — сравнение даты **по диапазону суток**.
4. Если не найден — `RegistrationService::createByUidAndBirthDate`:
- `email = md5((string) uid)` — синтетический идентификатор для колонки `email` / JWT `username`;
- регион по умолчанию `1` (аргумент сервиса);
- пароль по умолчанию: `hash( md5(uid . birthDateRaw) )` где первая часть — конкатенация строк из DTO перед нормализацией даты в сущности.
5. `updateLoggedIn`, `flush`, JWT в ответе.
## Mermaid
```mermaid
flowchart TD
A[POST /user/auth или /user/auth-by-pcode] --> V{Валидация DTO}
V -->|ошибка| E400[400 errors]
V -->|ок| B{Какой маршрут?}
B -->|auth| C[jwtAuth по uid]
C --> D{User найден?}
D -->|да| P[проверка password]
P -->|fail| E400b[400 неверный пароль]
P -->|ok| T[JWT + loggedIn]
D -->|нет| R[RegistrationService.create]
R --> T
B -->|auth-by-pcode| F[findOneByUidAndBirthDate]
F -->|есть| T
F -->|нет| G[createByUidAndBirthDate]
G -->|исключение| E500[500]
G -->|ok| T
```
## Внешние зависимости
| Система | Участие |
| --- | --- |
| PostgreSQL | `users` |
| Инфоклиника | **не вызывается напрямую** в этих методах; идентификатор приходит от клиента |
| JWT (Lexik) | выдача токена |
## Обработка ошибок и edge cases
- **Неверный формат даты** в `auth-by-pcode``400` с подсказкой по форматам.
- **Ошибка при создании пользователя** — `500` с текстом исключения (может раскрывать внутренние детали — вопрос hardening).
- **Коллизии `uid`**: на уровне сущности `User` есть ограничение уникальности `uid`; при конфликте БД вернёт ошибку при `flush` (в этом контроллере не разобрана отдельно).
## Ссылки на классы
- `apps/backend/src/Controller/UserController.php` (`auth`, `authByPcode`)
- `apps/backend/src/Service/User/AuthenticationService.php` (`jwtAuth`)
- `apps/backend/src/Service/User/RegistrationService.php` (`create`, `createByUidAndBirthDate`)
- `apps/backend/src/Dto/UserAuthDto.php`, `UserUidAuthDto.php`
- `apps/backend/src/Repository/UserRepository.php` (`findOneByUidAndBirthDate`)
См. [backend-ddd.md](../backend-ddd.md) (контекст Identity).
+67
View File
@@ -0,0 +1,67 @@
---
title: Создание лида Calltouch (Backend)
---
# Сценарий 4.2: Создание лида **Calltouch**
## Бизнес-цель
Маркетинг фиксирует обращения пользователей как **лиды** в системе **Calltouch** для сквозной аналитики и колл-центра.
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `POST /calltouch/create-lead` | `CalltouchController::createLead` |
**Ограничение доступа:** `#[IsGranted('ROLE_ADMIN')]` на уровне класса контроллера — вызов только для административной роли API.
## Реализация HTTP vs клиент
1. Тело (form/json) мапится в `CalltouchCreateRequestDto`, проходит `Validator`.
2. **Фактический вызов** `CalltouchClientService::requestCreate($dto)` в коде **закомментирован**; ответ клиента возвращён не будет.
3. Контроллер отдаёт `200` с полем `request` (сырые данные из `$request->request->all()`).
То есть **интеграция подготовлена, но в текущей ветке кода не активна** до раскомментирования строки.
## Как устроен `CalltouchClientService` (целевое поведение)
1. `configureHeaders($dto->regionId)` выбирает пару `siteId` + токен из строки ENV, разобранной в конструкторе (`param` формат `region:siteId:token,...`).
2. Формируется `POST` на путь **`/lead-service/v1/api/request/create`** заголовками `Access-Token`, `SiteId`.
3. Тело: `json_encode(['requests' => $dto->toArray()])`.
4. Возвращается `data` из JSON-ответа Calltouch.
## Mermaid (фактическое поведение в коде)
```mermaid
sequenceDiagram
participant A as Админ-клиент
participant CC as CalltouchController
participant V as Validator
A->>CC: POST /calltouch/create-lead
CC->>V: validate(CalltouchCreateRequestDto)
CC-->>A: 200 JSON (поле request)
```
После раскомментирования `CalltouchClientService::requestCreate` к схеме добавятся шаги HTTP `POST` к API Calltouch с заголовками `Access-Token` и `SiteId`.
## Внешние зависимости
| Система | Статус |
| --- | --- |
| Calltouch API | клиент реализован, HTTP в контроллере отключён |
| PostgreSQL | не используется в сценарии лида напрямую |
## Обработка ошибок и edge cases
- **Нет конфигурации региона** — `configureHeaders` бросает `InvalidArgumentException` (на будущее при включении клиента).
- **Опечатка в конструкторе** `CalltouchClientService`: два присваивания `$this->baseUrl` подряд — стоит перепроверить при включении интеграции.
## Ссылки на классы
- `apps/backend/src/Controller/CalltouchController.php`
- `apps/backend/src/Service/Client/CalltouchClientService.php`
- `apps/backend/src/Dto/CalltouchCreateRequestDto.php`
- `apps/backend/src/Service/Client/Interfaces/CalltouchClientServiceInterface.php`
См. [backend-ddd.md](../backend-ddd.md).
+74
View File
@@ -0,0 +1,74 @@
---
title: Смена региона пользователя (Backend)
---
# Сценарий 1.3: Смена региона пользователя
## Бизнес-цель
Пользователь может переключать **регион** (филиал сети / географическая зона), чтобы видеть контент и услуги, релевантные выбранной территории. В API регион хранится в **`User::$regionId`** и участвует в выдаче данных на фронте.
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `PUT /user/change-region` | `App\Controller\UserController::changeRegion` |
Требуется аутентификация: `#[IsGranted('ROLE_USER')]` — клиент передаёт **JWT** в заголовке.
CLI и Messenger **не используются**.
## Пошаговый алгоритм (flow)
1. Клиент отправляет JSON с полем `regionId`.
2. Данные попадают в `App\Dto\RegionDto`; Symfony Validator проверяет ограничения DTO.
3. Вызывается `App\Service\User\UserProfileService::updateRegion($dto)`:
- `JWTDecoderService::getUser()` — из текущего токена извлекается **username** и по нему загружается `User` из БД;
- на найденного пользователя выставляется `setRegionId($dto->regionId)`;
- `EntityManager::persist` + `flush`.
4. Контроллер сериализует **тот же** `User::toArray()` и возвращает `200` с `user` и `successful: true`.
## Mermaid
```mermaid
sequenceDiagram
participant C as Клиент (JWT)
participant UC as UserController
participant V as Validator
participant UPS as UserProfileService
participant JWT as JWTDecoderService
participant UR as UserRepository
participant EM as EntityManager
C->>UC: PUT /user/change-region {regionId}
UC->>V: validate(RegionDto)
UC->>UPS: updateRegion(dto)
UPS->>JWT: getUser()
JWT->>UR: find by token username
UR-->>JWT: User
UPS->>EM: setRegionId + flush
UPS-->>UC: User
UC-->>C: 200 { user, successful }
```
## Внешние зависимости
| Система | Участие |
| --- | --- |
| PostgreSQL | обновление строки в `users` |
| JWT | идентификация текущего пользователя |
## Обработка ошибок и edge cases
- **Невалидный `regionId`** — `400` с телом `errors` из Validator.
- **`getUser()` вернул null** — в текущей реализации сервиса нет явной проверки; при `ROLE_USER` обычно токен уже валиден, но при рассинхроне payload/БД возможна ошибка уровня type error или `flush` — сценарий стоит учитывать при доработках.
- **Справочник регионов**: этот эндпоинт **не проверяет**, существует ли `regionId` в таблице регионов — допустим любой int, прошедший валидацию DTO.
## Ссылки на классы
- `apps/backend/src/Controller/UserController.php` (`changeRegion`)
- `apps/backend/src/Service/User/UserProfileService.php`
- `apps/backend/src/Dto/RegionDto.php`
- `apps/backend/src/Service/DecoderJWT/JWTDecoderService.php`
См. [login-jwt.md](./login-jwt.md) и [backend-ddd.md](../backend-ddd.md).
+40
View File
@@ -0,0 +1,40 @@
---
title: Бизнес-сценарии Backend API — оглавление
---
# Бизнес-сценарии Backend API
Подробные потоки данных (**data flow**) от HTTP/CLI/Messenger до БД и внешних систем. Формат согласован с [архитектурой модулей](./../backend-architecture.md), [DDD-картой](./../backend-ddd.md) и [CRUD контента](./../backend-content-crud.md).
## Блок 1. Идентичность и профиль
| № | Сценарий | Файл |
| --- | --- | --- |
| 1.1 | Логин и JWT | [login-jwt.md](./login-jwt.md) |
| 1.2 | UID / pcode, привязка к `User` | [auth-uid-pcode.md](./auth-uid-pcode.md) |
| 1.3 | Смена региона | [change-region.md](./change-region.md) |
## Блок 2. Врачи, расписание, локации
| № | Сценарий | Файл |
| --- | --- | --- |
| 2.1 | Карточка врача и локации | [specialist-card-locations.md](./specialist-card-locations.md) |
| 2.2 | Расписание и кеш (таблица `schedule`) | [schedule-cache.md](./schedule-cache.md) |
| 2.3 | `GetScheduleMessage` и обработчик | [schedule-messenger.md](./schedule-messenger.md) |
| 2.4 | **Полный мануал: расписание Backend + Cabinet** | [doctor-schedule-sync.md](../doctor-schedule-sync.md) |
## Блок 3. Запись на приём
| № | Сценарий | Файл |
| --- | --- | --- |
| 3.1 | Анонимная запись (MIS) | [anonymous-reserve.md](./anonymous-reserve.md) |
| 3.2 | SMS, `Record`, `AlertSms` | [sms-record.md](./sms-record.md) |
| 3.3 | Киоск `clvisitsovacheckpass` | [kiosk-checkpass.md](./kiosk-checkpass.md) |
## Блок 4. Синхронизация и интеграции
| № | Сценарий | Файл |
| --- | --- | --- |
| 4.1 | Врачи (Infoclinica → `Idoctor`), Bitrix, отзывы | [sync-doctors-reviews.md](./sync-doctors-reviews.md) |
| 4.2 | Лид Calltouch | [calltouch-lead.md](./calltouch-lead.md) |
| 4.3 | XML-фид Яндекса | [xml-yandex-feed.md](./xml-yandex-feed.md) |
+75
View File
@@ -0,0 +1,75 @@
---
title: Отметка киоска clvisitsovacheckpass (Backend)
---
# Сценарий 3.3: Проверка / отметка киоска (`clvisitsovacheckpass`)
## Бизнес-цель
В филиале может стоять **киоск** самообслуживания. Когда пациент авторизован в приложении, backend фиксирует факт «проверки прохода» для пары **пациент (`pcode`/`uid`) + филиал**, чтобы киоск знал, можно ли продолжить сценарий (метод возвращает булев признак `isResult()` у сущности `MarkKiosk`).
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `GET /infoclinica/clvisitsovacheckpass/{filial}` | `InfoclinicaController::clvisitsovacheckpass` |
Доступ: `#[IsGranted('ROLE_USER')]` — нужен JWT. `filial` — целочисленный идентификатор из URL.
## Что такое `pcode` здесь
Внутри метода берётся **текущий пользователь** через `JWTDecoderService::getUser()`, далее `$user->getUid()` трактуется как **`pcode` пациента** для записи в `MarkKiosk`. То есть это **тот же числовой uid**, что хранится в `users.uid` после сценариев [auth-uid-pcode.md](./auth-uid-pcode.md).
## Пошаговый алгоритм
1. Проверка JWT; если пользователь не найден — `401` с телом `{"error":"Пользователь не найден"}`.
2. `$pcode = $user->getUid()`.
3. Репозиторий `MarkKiosk` ищет запись по паре `['pcode' => $pcode, 'filial' => $filial]`.
4. Если записи нет — создаётся `new MarkKiosk()` с `pcode`, `filial`, `createdAt`/`modifyAt` (текущее время), `persist` + `flush`.
5. Повторная выборка той же сущности (как в коде после создания).
6. Ответ API: JSON c булевым полем из `MarkKiosk::isResult()` — для **новой** строки поле `result` в сущности по умолчанию **`null`** (в setter при создании в контроллере `result` не выставляется).
## Mermaid
```mermaid
sequenceDiagram
participant K as Киоск / клиент
participant IC as InfoclinicaController
participant JWT as JWTDecoderService
participant EM as EntityManager
participant MK as MarkKiosk
K->>IC: GET .../clvisitsovacheckpass/{filial} + JWT
IC->>JWT: getUser()
alt нет пользователя
IC-->>K: 401
else ok
IC->>EM: find MarkKiosk pcode+filial
alt нет строки
IC->>EM: persist новый MarkKiosk
end
EM-->>IC: MarkKiosk
IC-->>K: {result: isResult()}
end
```
## Внешние зависимости
| Система | Роль |
| --- | --- |
| PostgreSQL | таблица `mark_kiosk` (имя по маппингу Doctrine) |
| Инфоклиника | **не вызывается** в этом методе; имя маршрута исторически связано с MIS |
## Обработка ошибок и edge cases
- **Нет JWT / неверный токен** — ответ средства Symfony Security (не разобран в контроллере).
- **Повторный вызов** — запись уже есть; логика `isResult()` определяет, что отдать киоску при повторе (см. `Entity/MarkKiosk.php`).
- **У пользователя нет `uid`** — маловероятно по модели, но при `0` возможны коллизии — вопрос целостности данных.
## Ссылки на классы
- `apps/backend/src/Controller/InfoclinicaController.php`
- `apps/backend/src/Entity/MarkKiosk.php`
- `apps/backend/src/Service/DecoderJWT/JWTDecoderService.php`
Карта домена: [backend-ddd.md](../backend-ddd.md).
+85
View File
@@ -0,0 +1,85 @@
---
title: Логин по email и выдача JWT (Backend)
---
# Сценарий 1.1: Логин и JWT
## Бизнес-цель
Пользователь сайта или приложения входит по **email и паролю**. Система проверяет учётные данные, обновляет отметку последнего входа и выдаёт **JWT**, чтобы дальнейшие запросы к API выполнялись от имени этого пользователя без хранения сессии на сервере (stateless API).
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `POST /user/login` | `App\Controller\UserController::login` |
| HTTP | `GET /user/logout` | `App\Controller\UserController::logout` (ответ-заглушка; инвалидация токена на сервере не показана в коде) |
| HTTP | `GET /user/` | `App\Controller\UserController::index` — текущий пользователь по JWT (`#[IsGranted('ROLE_USER')]`) |
Асинхронные сообщения и CLI для этого сценария **не используются**.
## Пошаговый алгоритм (flow)
1. Клиент отправляет JSON с полями `username` (фактически email) и `password` (см. OpenAPI в контроллере).
2. `UserController::login` вручную парсит тело; при отсутствии полей отвечает `400` с текстом `Missing credentials`.
3. Значения кладутся в `App\Dto\UserLoginDto`; срабатывает Symfony Validator по атрибутам DTO.
4. Вызывается `App\Service\User\AuthenticationService::jsonAuth($dto)`:
- в `App\Repository\UserRepository` выполняется поиск `User` по **`email = md5(введённый email)`** (в БД в колонке `users.email` хранится **хэш**, а не открытый email);
- пароль проверяется через `UserPasswordHasherInterface::isPasswordValid`.
5. При неверной паре или отсутствии пользователя контроллер возвращает `400` с сообщением о неверных учётных данных.
6. При успехе вызывается `$user->updateLoggedIn()`, `EntityManager::flush()` — в БД обновляется поле `loggedIn`.
7. Lexik JWT: `JWTTokenManagerInterface::create($user)` формирует токен; в ответ уходит JSON: `successful`, `token`, `user` (массив из `User::toArray()``uid`, `bdate`, `roles`, `regionId`, `loggedIn`).
## Mermaid
```mermaid
sequenceDiagram
participant C as Клиент
participant UC as UserController
participant V as Validator
participant AS as AuthenticationService
participant UR as UserRepository
participant EM as EntityManager
participant JWT as JWTTokenManagerInterface
C->>UC: POST /user/login {username, password}
UC->>V: validate(UserLoginDto)
V-->>UC: ок / ошибки
UC->>AS: jsonAuth(dto)
AS->>UR: findOneBy email = md5(dto.email)
UR-->>AS: User | null
AS-->>UC: {user, isPasswordValid}
alt неверный пароль или нет пользователя
UC-->>C: 400
else успех
UC->>EM: flush (updateLoggedIn)
UC->>JWT: create(User)
UC-->>C: 200 {token, user}
end
```
## Внешние зависимости
| Система | Участие |
| --- | --- |
| PostgreSQL | таблица `users` (`App\Entity\User`) |
| Lexik JWT | генерация и последующая валидация токена |
| Bitrix / Инфоклиника / Redis / SMS | **не задействованы** в этом сценарии |
## Обработка ошибок и edge cases
- **Нет полей credentials** — `400`, `Missing credentials`.
- **Ошибки валидации DTO** — `400`, `successful: false`, `errors` (строка нарушений).
- **Неверный email/пароль** — единый ответ `400` с текстом «Не правильное имя пользователя или пароль».
- **Согласованность с JWT**: `App\Service\DecoderJWT\JWTDecoderService::getUser()` восстанавливает пользователя по `username` из payload и полю `email` в БД — это тот же «логин», что хранится в `User` после регистрационных сценариев (часто `md5(...)`).
## Ссылки на классы
- `apps/backend/src/Controller/UserController.php`
- `apps/backend/src/Service/User/AuthenticationService.php`
- `apps/backend/src/Dto/UserLoginDto.php`
- `apps/backend/src/Entity/User.php`
- `apps/backend/src/Repository/UserRepository.php`
- Конфигурация security/JWT: `apps/backend/config/packages/security.yaml`, `lexik_jwt_authentication.yaml`
См. также [backend-ddd.md](../backend-ddd.md).
+113
View File
@@ -0,0 +1,113 @@
---
title: Расписание врача и кеш слотов (Backend)
---
# Сценарий 2.2: Расписание и «кеш» слотов
## Бизнес-цель
Пациенту показывают **свободные интервалы** записи к врачу в конкретном филиале и режиме (очный / онлайн). Чтобы не дёргать MIS на каждый запрос, backend хранит **недавно полученное расписание** и отдаёт его при повторных обращениях с теми же параметрами.
**Важно для архитектуры:** несмотря на название сервиса, **данные кеша живут в PostgreSQL** (таблица сущности `Schedule`), а не в Redis. Redis в этом сценарии **не используется** (см. `ScheduleCacheService`).
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `GET /specialist/schedule?...` | `SpecialistController::specialistSchedule` |
| CLI | `app:schedule:clear-cache` | `ClearScheduleCacheCommand` (очистка старых строк) |
Фактический вызов MIS выполняется внутри `GetScheduleMessageHandler` (см. [schedule-messenger.md](./schedule-messenger.md)).
## Параметры запроса расписания
Используется `App\Dto\ScheduleDto`:
- `st`, `en` — границы времени (целые);
- `dcode` — код врача;
- `filial` — филиал (в query строка строится как `filialId`);
- `onlineMode` — признак онлайн-расписания.
`ScheduleDto::toQueryString()` формирует строку для поиска в кеше и запроса к MIS (HTTP query).
## Пошаговый алгоритм HTTP
1. Контроллер наполняет DTO из query, валидирует.
2. `SpecialistService::getSchedule($dto)` диспатчит `GetScheduleMessage` (см. отдельную статью).
3. Хендлер сначала зовёт `ScheduleCacheService::getCachedSchedule($queryString, $isOnlineMode)`.
## Логика `ScheduleCacheService`
### TTL («как долго живёт кеш»)
Константа **`CACHE_TTL_MINUTES = 5`**.
`getCachedSchedule`:
- вычисляет порог `createdAt >= now - 5 minutes`;
- `ScheduleRepository::findByQueryModeAndTime($queryString, $isOnlineMode, $createdAfter)`;
- если строк нет — `null` (промах кеша).
То есть **инвалидация по времени**: записи старше 5 минут не считаются валидными для ответа.
### Запись и перезапись
`saveSchedule` перед вставкой новых слотов вызывает **`removeByQueryStringAndMode`**: удаляет все записи кеша с тем же `queryString` и `onlineMode`. Это **жёсткое обновление** среза расписания под ключ запроса.
Каждый слот — строка `Schedule` с полями врача, отделения, даты, интервала, `queryString`, `onlineMode`, `createdAt` и др.
### Ошибки чтения из БД
В `getCachedSchedule` перехват `Exception` → лог `Error reading from cache` → возврат `null` (как промах кеша, дальше пойдут в MIS).
## Инвалидация / уборка
| Механизм | Описание |
| --- | --- |
| По TTL при чтении | старше 5 минут не отдаются |
| `saveSchedule` | удаление предыдущих строк с тем же ключом перед insert |
| `app:schedule:clear-cache --hours=N` | массовое удаление по `createdAt < now - N hours` через `clearOldCache` |
| Опция `--stats` | статистика по таблице без удаления |
## `ScheduleErrorHandlerService`
Не часть кеша; вызывается из хендлера при ошибках HTTP-клиента или непойманных исключений: логирование и возврат массива с `status_code`, телом ответа MIS, длительностью и т.д. (см. [schedule-messenger.md](./schedule-messenger.md)).
## Mermaid
```mermaid
flowchart TD
A[GET /specialist/schedule] --> B[ScheduleDto + validate]
B --> C[SpecialistService.getSchedule]
C --> H[GetScheduleMessageHandler]
H --> D{Есть свежие Schedule строки?}
D -->|да ≤5 мин| R[reconstructFromDatabase → ответ cached]
D -->|нет| E[HTTP MIS intervals]
E --> S[saveSchedule: delete old + insert Schedule rows]
S --> R2[ответ api + _meta]
```
## Внешние зависимости
| Система | Роль |
| --- | --- |
| PostgreSQL | хранение «кеша» `Schedule` |
| Инфоклиника (MIS) | источник расписания при промахе |
| Redis | **не используется** в этом сценарии по текущему коду |
## Обработка ошибок и edge cases
- **БД недоступна при чтении кеша** — лог, ответ пойдёт в MIS; если и MIS недоступна — ошибка из хендлера.
- **Пустой ответ MIS** — `normalize` / пустые массивы зависят от клиента (см. `InfoclinicaClientService`).
- **Несогласованность `onlineMode` в DTO**: в контроллере в поле может попадать `0|1` из query — при строгой типизации возможны проблемы валидации (наблюдение для джуна при отладке).
## Ссылки на классы
- `apps/backend/src/Controller/SpecialistController.php` (`specialistSchedule`)
- `apps/backend/src/Dto/ScheduleDto.php`
- `apps/backend/src/Service/ScheduleCache/ScheduleCacheService.php`
- `apps/backend/src/Repository/ScheduleRepository.php`
- `apps/backend/src/Command/ClearScheduleCacheCommand.php`
- `apps/backend/src/Service/ErrorHandler/ScheduleErrorHandlerService.php`
Связанный сценарий Messenger: [schedule-messenger.md](./schedule-messenger.md).
@@ -0,0 +1,90 @@
---
title: Асинхронное сообщение GetScheduleMessage (Backend)
---
# Сценарий 2.3: `GetScheduleMessage` и `GetScheduleMessageHandler`
## Бизнес-цель
Получение расписания спроектировано через **Symfony Messenger**, чтобы:
- отделить HTTP-слой от интеграции с MIS и работы с кешем;
- унифицировать вызовы (тот же message может диспатчиться из других мест);
- заложить возможность смены транспорта с `sync` на очередь без переписывания контроллера.
По факту конфигурации в `config/packages/messenger.yaml` маршрут для `App\Message\GetScheduleMessage` указывает на транспорт **`sync://`**, то есть обработка **синхронная в том же PHP-процессе**, а не отложенная очередь.
## Точки входа
| Тип | Где создаётся сообщение | Класс |
| --- | --- | --- |
| HTTP | `GET /specialist/schedule``SpecialistService::getSchedule` | `App\Message\GetScheduleMessage` |
| Messenger | обработчик | `App\MessageHandler\GetScheduleMessageHandler` |
CLI для этого сообщения отдельно не зарегистрирован в коде репозитория.
## Пошаговый алгоритм
1. `SpecialistService::getSchedule` создаёт `new GetScheduleMessage($dto->toQueryString(), $dto->onlineMode)` и диспатчит через `MessageBusInterface`.
2. `GetScheduleMessageHandler::__invoke`:
- стартует `PerformanceTrackerService`;
- **кеш-hit**: `ScheduleCacheService::getCachedSchedule` — при успехе возвращает данные + `_meta.source = cached`;
- **кеш-miss**: `InfoclinicaClientService::getSchedule($queryString, $isOnlineMode)` (второй аргумент — в коде хендлера; фактическая сигнатура клиента может отличаться — см. раздел Edge cases);
- при успешном ответе MIS — `ScheduleCacheService::saveSchedule` и ответ с `_meta.source = api`;
- при `HttpExceptionInterface``ScheduleErrorHandlerService::handleHttpException` возвращает массив диагностики;
- при прочих исключениях — `handleGeneralException`.
## Mermaid
```mermaid
sequenceDiagram
participant SC as SpecialistController
participant SS as SpecialistService
participant BUS as MessageBus
participant H as GetScheduleMessageHandler
participant CACHE as ScheduleCacheService
participant MIS as InfoclinicaClientService
participant ERR as ScheduleErrorHandlerService
SC->>SS: getSchedule(ScheduleDto)
SS->>BUS: dispatch(GetScheduleMessage)
BUS->>H: __invoke
H->>CACHE: getCachedSchedule
alt hit
CACHE-->>H: данные слотов
else miss
H->>MIS: getSchedule (HTTP)
alt MIS OK
MIS-->>H: JSON нормализован
H->>CACHE: saveSchedule
else HTTP error
MIS-->>ERR: HttpException
ERR-->>H: error payload
end
end
H-->>SS: результат массива
SS-->>SC: JsonResponse
```
## Внешние зависимости
| Система | Роль |
| --- | --- |
| PostgreSQL (`Schedule`) | кеш слотов |
| Инфоклиника | `GET /api/reservation/intervals?{query}` (см. `InfoclinicaClientService`) |
| Логирование | канал `infoclinica-cache`, `infoclinica-error` (через `withName`) |
## Обработка ошибок и edge cases
- **Ошибка HTTP MIS** — не исключение наружу; возвращается массив с `status_code`, `response` и др. Контроллер отдаёт его как JSON `200` с этим телом (поведение «мягкой ошибки» — важно для фронта).
- **Рассинхрон сигнатур**: в `GetScheduleMessageHandler` вызов `getSchedule` с двумя аргументами должен соответствовать PHP-интерфейсу клиента; при несоответствии будет `ArgumentCountError` на старте (сигнал провести рефакторинг интерфейса `InfoclinicaClientServiceInterface` и реализации).
- **sync-транспорт**: нет повторного выполнения из failed queue для этого message в штатной конфигурации (в отличие от реальной async-очереди).
## Ссылки на классы
- `apps/backend/src/Message/GetScheduleMessage.php`
- `apps/backend/src/MessageHandler/GetScheduleMessageHandler.php`
- `apps/backend/src/Service/Specialist/SpecialistService.php`
- `apps/backend/config/packages/messenger.yaml` (`routing` для `GetScheduleMessage`)
Дополнительно: кеш в БД — [schedule-cache.md](./schedule-cache.md).
+61
View File
@@ -0,0 +1,61 @@
---
title: SMS-уведомления и сущности Record / AlertSms (Backend)
---
# Сценарий 3.2: Уведомления (SMS), связь `Record` ↔ `AlertSms`
## Бизнес-цель
После записи к врачу пациент может получить **SMS** (напоминание, код подтверждения и т.п.). В модели данных предусмотрено локальное хранение **факта записи** (`Record`) и **ответа SMS-провайдера** (`AlertSms`) в связке один-к-одному.
## Точки входа (фактический код)
| Компонент | Назначение |
| --- | --- |
| `App\Entity\Record` | Телефон, `specialistId`, `hash`, JSON-блоб `reserve`, время создания. |
| `App\Entity\AlertSms` | Ссылка на `Record`, время, текст/ответ провайдера. |
| `Sms4bClientService`, `SmsruClientService` | Реализации `SmsClientServiceInterface` (HTTP-клиенты). |
**Поиск по дереву `apps/backend/src`:** вызовов `Sms4bClientService` / `SmsruClientService` или `RecordRepository` из контроллеров и обработчиков **не обнаружено**. То есть **интеграция SMS заложена на уровне инфраструктуры/сервисов, но не подключена к HTTP-сценарию анонимной записи** в этом репозитории на момент документирования.
## Как бы выглядел целевой flow (рекомендуемая логика)
1. После успешного ответа MIS о записи backend создаёт `Record` с телефоном и сериализованным `reserve`.
2. Асинхронно или синхронно вызывается SMS-клиент с текстом шаблона.
3. Ответ провайдера сохраняется в `AlertSms`, линкуется через `Record::setAlertSms` (Doctrine `OneToOne`).
## Mermaid (целевая схема — не полностью реализована в коде)
```mermaid
sequenceDiagram
participant API as Backend API
participant DB as PostgreSQL
participant SMS as SMS шлюз (Sms4b / sms.ru)
API->>DB: persist Record
API->>SMS: отправить SMS
SMS-->>API: ответ / статус
API->>DB: persist AlertSms ↔ Record
```
## Внешние зависимости
| Система | Статус в коде |
| --- | --- |
| Sms4b / sms.ru | Классы-клиенты есть |
| PostgreSQL | Таблицы под `Record` / `AlertSms` предполагаются миграциями |
## Обработка ошибок и edge cases
- **Нет вызова SMS** — риск «тихого» пропуска уведомления; фронт не может отличить по API, если нет явного шага.
- **Повторная отправка** — нужна идемпотентность по `hash` записи (поле в `Record`) — в коде не просматривалось.
## Ссылки на классы
- `apps/backend/src/Entity/Record.php`
- `apps/backend/src/Entity/AlertSms.php`
- `apps/backend/src/Repository/RecordRepository.php`, `AlertSmsRepository.php`
- `apps/backend/src/Service/Client/Sms4bClientService.php`
- `apps/backend/src/Service/Client/SmsruClientService.php`
Модель данных: [data-model.md](../../data-model.md). Сценарий записи в MIS: [anonymous-reserve.md](./anonymous-reserve.md).
@@ -0,0 +1,86 @@
---
title: Карточка врача и локации приёма (Backend)
---
# Сценарий 2.1: Карточка врача и локации (`Specialist` + `Location`)
## Бизнес-цель
Пользователю нужна **карточка врача** (ФИО, медиа, признаки активности, регион и т.д.) вместе с **локациями приёма**: привязка к отделению, филиалу, режиму online и пр., чтобы выбрать место и сценарий записи.
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `GET /specialist/{id}` | `SpecialistController::show` (`id` — целое) |
| HTTP | `GET /specialist/by/{identifier}?regionId=` | `SpecialistController::showBy` |
Список врачей: `GET /specialist/list` — отдельный сценарий фильтрации, тот же репозиторий.
CLI / Messenger для **чтения карточки** не используются.
## Как собирается «агрегат» в рамках Symfony / ORM
Doctrine-сущность `App\Entity\Specialist` содержит `OneToMany` на `App\Entity\Location` (`mappedBy: 'specialist'`, каскады `persist`/`remove`). Для API **отдельного сервиса-сборщика нет**: при `GET /specialist/{id}` используется param converter и сериализация.
Группы Serializer **`specialist:detail`** и **`from.specialist:read`** включают в ответ связанные локации (см. атрибуты `Groups` на поле коллекции локаций в `Specialist`).
Маршрут **`showBy`**:
1. `SpecialistService::getSpecialist($identifier, $regionId)`;
2. если `identifier` числовой — выборка по `id`;
3. иначе — по `alias` и опциональному `regionId` через `SpecialistRepository::createFilteredQueryBuilder`;
4. при `null`**404** `{"error":"not found"}`.
## Пошаговый алгоритм — `GET /specialist/{id}`
1. Загрузка `Specialist` из БД по `id` (или 404 на уровне фреймворка, если не найден).
2. `JsonResponse` с группами `specialist:detail`, `from.specialist:read`.
3. При обходе графа сериализатором Doctrine догружает `locations`.
## Mermaid
```mermaid
sequenceDiagram
participant C as Клиент
participant SC as SpecialistController
participant SS as SpecialistService
participant SR as SpecialistRepository
participant SER as Serializer / ORM
alt По числовому id
C->>SC: GET /specialist/{id}
SC->>SER: serialize(Specialist)
SER->>SER: подтягивание Location
SC-->>C: JSON
else По alias
C->>SC: GET /specialist/by/{identifier}
SC->>SS: getSpecialist
SS->>SR: QueryBuilder
SR-->>SS: Specialist | null
SS-->>SC: entity
SC->>SER: serialize
SC-->>C: 200 или 404
end
```
## Внешние зависимости
| Система | Участие |
| --- | --- |
| PostgreSQL | данные `specialist` и `location` |
| Инфоклиника / Bitrix | **не вызываются** при чтении карточки |
## Обработка ошибок и edge cases
- **Не найден по alias** — `404` с простым телом.
- **Фото врача**: отдельные эндпоинты загрузки картинки (`specialistPicture`, upload) — не часть сценария «только чтение».
## Ссылки на классы
- `apps/backend/src/Controller/SpecialistController.php`
- `apps/backend/src/Service/Specialist/SpecialistService.php`
- `apps/backend/src/Entity/Specialist.php`, `Entity/Location.php`
- `apps/backend/src/Repository/SpecialistRepository.php`
Админские CRUD по локациям: `LocationController`. Карта домена: [backend-ddd.md](../backend-ddd.md).
@@ -0,0 +1,115 @@
---
title: Синхронизация врачей и отзывов (Backend)
---
# Сценарий 4.1: Синхронизация врачей и отзывов (Infoclinica + Bitrix)
## Бизнес-цель
Актуализировать справочники backend из внешних систем:
- **Список врачей из Инфоклиники** загружается и складывается в PostgreSQL (сущность **`Idoctor`** — staging/интеграционная модель).
- **Нормализация признаков `Specialist`** из Bitrix-related данных (команда **`bitrix-update-doctors`** — очистка `dcodes`).
- **Отзывы** подтягиваются **из MySQL Bitrix** через `BitrixService` и сохраняются как `Review`, связанные с `Specialist`.
## Точки входа
| Тип | Имя команды Symfony | Класс |
| --- | --- | --- |
| CLI | `upload:doctors` | `UploadDoctorsCommand` |
| CLI | `bitrix-update-doctors` | `BitrixUpdateDoctorsCommand` |
| CLI | `bitrix-update-reviews` | `BitrixUpdateReviewsCommand` |
Все три предназначены для **cron** или ручного запуска в контейнере `php84`.
## Сценарий A — `upload:doctors` (Инфоклиника → `Idoctor`)
### Алгоритм
1. Читает активные отделения из PostgreSQL (`Department`, опция `--department` для одного `did`).
2. Для каждого отделения в цикле вызывает HTTP **через клиент Инфоклиники**: `GET /specialists/doctors?departments={did}&onlineMode={0|1}&firstrow=&lastrow=` чанками (`CHUNK_SIZE` 300).
3. Для каждого врача определяется ключ `"{dcode}_{departmentId}_{onlineMode}"`, подгружаются существующие `Idoctor` тем же ключом.
4. `updateDoctorEntity` обновляет поля (`dcode`, `name`, `department`, `filial`, `nearestDate`, `onlineMode`), `persist`, пакетный `flush` каждые `BATCH_SIZE` (150).
5. Между отделениями — `sleep(1)`; между чанками — `usleep(200000)`.
### «Конфликты»
Явного SQL `ON CONFLICT` нет: используется **ORM upsert-паттерн** — найти сущность по составному ключу в PHP или создать `new Idoctor()`, затем `persist`.
## Сценарий B — `bitrix-update-doctors` (PostgreSQL `Specialist`)
### Алгоритм
1. Загружает **все** `Specialist` из БД.
2. Нормализует строку `dcodes`: для каждого врача фильтрует коды длиной ≥ 7, отбрасывает `'0'`, пустые наборы превращает в `null`.
3. `flush` один раз в конце.
4. Обращение к `BitrixService` для `kodoper` **закомментировано** в текущей версии файла.
**Это не загрузка врачей из Bitrix**, а офлайн-очистка данных в уже существующей таблице `specialist`.
## Сценарий C — `bitrix-update-reviews` (MySQL Bitrix → PostgreSQL `Review`)
### Алгоритм
1. Постранично обходит `Specialist` батчами по 5 записей.
2. Для каждого врача `BitrixService::getReviews($specialist->getId())`:
- читает связанные элементы инфоблоков в **MySQL** (`doctrine.dbal.mysql_connection`);
- для каждого отзыва известен `REVIEW_ID`.
3. В PostgreSQL ищется `Review` с тем же `externalId`; если нет — `new Review()` + `setExternalId`.
4. Поля текста, рейтинга, автора, даты, активности заполняются из структуры Bitrix (включая «распаковку» сериализованных полей в `getReviews`).
5. Неактивные или без текста — пропуск.
6. `$specialist->addReview($review)`, `flush`; при ошибке драйвера — логирование проблемных UTF-8 последовательностей.
### «Конфликты»
Снова **без `ON CONFLICT`**: идемпотентность за счёт поиска по `externalId` перед вставкой.
## Mermaid
```mermaid
flowchart LR
subgraph MIS["Инфоклиника"]
API["GET /specialists/doctors"]
end
subgraph PG["PostgreSQL"]
ID["Idoctor"]
SP["Specialist"]
RV["Review"]
end
subgraph BX["Bitrix MySQL"]
IB["Инфоблоки отзывов"]
end
UC["upload:doctors"] --> API
API --> ID
BD["bitrix-update-doctors"] --> SP
BR["bitrix-update-reviews"] --> IB
BR --> RV
RV --> SP
```
Узлы `UC` / `BD` / `BR` — это команды `upload:doctors`, `bitrix-update-doctors`, `bitrix-update-reviews`.
## Внешние зависимости
| Система | Сценарий |
| --- | --- |
| Инфоклиника HTTP | `upload:doctors` |
| PostgreSQL | все три команды |
| Bitrix MySQL | `bitrix-update-reviews` (и потенциально расширения `BitrixService`) |
## Обработка ошибок и edge cases
- **Сетевые ошибки загрузки врачей** — warning в консоли, переход к следующему чанку/отделению.
- **Проблемные отзывы** — `DriverException` логируется с дампом полей.
- **`BitrixService` зависит от `regionId` в некоторых методах** — убедитесь, что выставление региона покрыто в вашем окружении при расширении команды.
## Ссылки на классы
- `apps/backend/src/Command/UploadDoctorsCommand.php`
- `apps/backend/src/Command/BitrixUpdateDoctorsCommand.php`
- `apps/backend/src/Command/BitrixUpdateReviewsCommand.php`
- `apps/backend/src/Service/Bitrix/BitrixService.php`
- `apps/backend/src/Entity/Idoctor.php`, `Specialist.php`, `Review.php`
См. [backend-ddd.md](../backend-ddd.md) и [data-model.md](../../data-model.md).
+68
View File
@@ -0,0 +1,68 @@
---
title: Генерация XML-фида для Яндекса (Backend)
---
# Сценарий 4.3: XML-фиды (Яндекс)
## Бизнес-цель
Сгенерировать **YML/XML фид** для рекламных / информационных площадок (исторически Яндекс): в фид попадают **врачи**, **клиники/услуги**, **цены**, тексты и URL с UTM-метками.
## Точки входа
| Тип | Метод + URL | Класс |
| --- | --- | --- |
| HTTP | `GET /xml/feed` | `XmlFeedController::generateFeed` |
| HTTP | `GET /xml/feed/v1` | `XmlFeedController::generateFeedV1` — выбор филиалов по `filials` (csv `fid`) или по `regionId` |
Для `/xml/feed`: query-параметр `filial` — числовой `fid`; поддерживаются UTM-поля.
## Пошаговый алгоритм (`generateFeed`)
1. `FilialRepository::findOneBy(['fid' => filialId])` — если филиал **не найден**, контроллер возвращает **пустой `Response` (HTTP 200 без тела)** — в коде нет явного статуса ошибки.
2. Собираются UTM-параметры в массив (utm_source, utm_medium, utm_campaign, ...).
3. `XmlFeedGeneratorService::generateFeed($filial, $utmParams)` строит `DOMDocument`:
- `addShopInfo` — метаданные магазина/сети;
- `addDoctors` — врачи филиала (`SpecialistService::getList` с фильтром `active=true`, `filial=fid`);
- `addClinics`, `addServices`, `addOffers` — прайс и структура услуг через `PriceListService`, отделения через `DepartmentService`, локации через `LocationService`, филиалы через `FilialService`, тексты dcode через `SpecialistDcodeDescriptionRepository`.
4. Контроллер возвращает `Response` с `Content-Type: application/xml` и телом `saveXML()`.
Версия **V1** использует `XmlFeedGeneratorV1Service` — та же идея, другой шаблон дерева XML (см. класс).
## Mermaid
```mermaid
flowchart TD
C[GET /xml/feed?filial=...] --> F{Филиал найден?}
F -->|нет| E404[Пустой Response 200]
F -->|да| X[XmlFeedGeneratorService.generateFeed]
X --> D[SpecialistService.getList]
X --> P[PriceListService.getList]
X --> L[LocationService / DepartmentService]
X --> DOM[DOMDocument saveXML]
DOM --> R[Response XML]
```
## Внешние зависимости
| Система | Роль |
| --- | --- |
| PostgreSQL | все справочники и врачи |
| Инфоклиника / Bitrix | **не вызываются** при генерации |
| Redis | **не используется** |
## Обработка ошибок и edge cases
- **Пустой/неверный `filial`** — пустой ответ `200` без XML (так задумано в текущем контроллере).
- **Большой XML** — генерация синхронна; долгие запросы на проде стоит кешировать на уровне nginx/CDN или вынести в задачу.
- **Пустые списки врачей/цен** — фид всё равно строится, но может не пройти требования площадки — нужна валидация бизнес-правил.
## Ссылки на классы
- `apps/backend/src/Controller/XmlFeedController.php`
- `apps/backend/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php`
- `apps/backend/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php`
- `apps/backend/src/Service/Specialist/SpecialistService.php`
- `apps/backend/src/Service/PriceList/PriceListService.php`
См. [backend-architecture.md](../backend-architecture.md) и [backend-ddd.md](../backend-ddd.md).