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
+416
View File
@@ -0,0 +1,416 @@
# adminPanel: CRUD контентных сущностей
Подробное описание UI для [backend CRUD контента](/apps/backend-content-crud). Общие соглашения админки (layout, RTK, переиспользуемые компоненты): [adminPanel: обзор](/apps/admin-panel).
## Задача (#27)
Дать контент-менеджерам экраны в `apps/adminPanel` для шести сущностей backend API:
| Сущность | Раздел меню | Backend API |
| --- | --- | --- |
| Новости | Новости | `/news` |
| Промо (контент) | Промо (контент) | `/promo` |
| Заболевания | Заболевания | `/disease` |
| Медцентры | Медцентры | `/medical-center` |
| Статьи | Статьи | `/article` |
| Услуги сайта | Услуги сайта | `/site-services` |
**Не путать** с разделом **«Акции»** (`/promotions`) — это сущность `stock` (отдельные `apiStock.js`, `StoksListPage`, `EditStockPage`).
Требования к UI:
- единый CRUD-подход для всех шести ресурсов (один код списка и формы, конфиг полей);
- список с поиском и серверной пагинацией;
- создание / редактирование / удаление;
- ошибки валидации **на форме** (красная подсветка полей), **без** `window.alert` при сохранении;
- успешное сохранение — `Modal`, как у акций.
## Ветки Git
| Ветка | Подход | Назначение |
| --- | --- | --- |
| **`issues/27-future`** | Generic: `ContentListPage`, `ContentEditPage`, `apiContent` | Рекомендуемая реализация с виджетами полей и `parseSaveError` |
| `issues/27` | Копия `/promotions`: отдельные `*ListPage` / `Edit*Page` / `api*` на ресурс | Интеграционная / альтернативная ветка |
| `issues/27-refactor` | То же, что `issues/27` (от `dev`) | Исторический MR «как акции» |
| `dev` | Без контентного CRUD | База |
Backend: [ветки и MR](/apps/backend-content-crud#ветки-git-и-mr-файлы) — `issues/27` на backend **не трогать**.
Дальше в документе описан код ветки **`issues/27-future`**.
## Ресурсы: UI-маршруты и API
У каждого ресурса **три маршрута** в React Router и один `basePath` в RTK Query.
| Ключ конфига | `slug` (UI) | Список | Создание | Редактирование | `basePath` (API) |
| --- | --- | --- | --- | --- | --- |
| `news` | `/news` | `/news` | `/news/create` | `/news/edit/:id` | `/news` |
| `promo` | `/site-promo` | `/site-promo` | `/site-promo/create` | `/site-promo/edit/:id` | `/promo` |
| `disease` | `/disease` | `/disease` | `/disease/create` | `/disease/edit/:id` | `/disease` |
| `medical-center` | `/medical-center` | `/medical-center` | `/medical-center/create` | `/medical-center/edit/:id` | `/medical-center` |
| `article` | `/article` | `/article` | `/article/create` | `/article/edit/:id` | `/article` |
| `site-services` | `/site-services` | `/site-services` | `/site-services/create` | `/site-services/edit/:id` | `/site-services` |
`slug` — сегмент в URL админки. `basePath` — префикс HTTP к backend (может отличаться, напр. промо: UI `site-promo`, API `promo`).
### Соответствие HTTP backend
| Действие в UI | RTK hook | HTTP |
| --- | --- | --- |
| Список | `useListQuery({ search, page, perPage })` | `GET {basePath}/list?...` |
| Карточка | `useItemQuery(id)` | `GET {basePath}/{id}` |
| Создание | `useCreateMutation(data)` | `POST {basePath}/create` |
| Обновление | `useUpdateMutation({ id, data })` | `PUT {basePath}/{id}` |
| Удаление | `useDeleteMutation(id)` | `DELETE {basePath}/{id}` |
Write-запросы отправляют `Authorization: Bearer` через `authHeader()` в `apiContent.js`.
Пагинация списка:
- по умолчанию: `?page=1&perPage=20&search=...`;
- **статьи**: `listUsesLimit: true``limit` вместо `perPage`;
- ответ: `{ data, pagination }` или для статей `{ data, meta }` — UI нормализует в `normalizePagination()`.
## Общая схема
```mermaid
flowchart TD
subgraph routes [React Router]
List["/{slug}"]
Create["/{slug}/create"]
Edit["/{slug}/edit/:id"]
end
subgraph pages [pages/content]
Index["index.jsx → NewsListPage, …"]
CLP[ContentListPage]
CEP[ContentEditPage]
end
subgraph config [Конфигурация]
CR[contentResources.js]
Hooks[contentHooks в apiContent.js]
end
List --> Index --> CLP
Create --> Index --> CEP
Edit --> Index --> CEP
CLP --> Hooks
CEP --> Hooks
CLP --> CR
CEP --> CR
Hooks --> Slice[apiSlice + baseUrl]
Slice --> API[Backend Symfony CRUD]
CEP --> Parse[parseSaveError]
CEP --> Widgets[ContentField / FieldHint]
CEP --> Shell[EditElementForm + Modal]
```
## Ключевой принцип
Страница **не знает**, сколько полей у «новости» или «услуги». Она получает `config` и `hooks` и рисует форму по `config.fields` и таблицу по `config.listColumns`.
Добавление седьмого ресурса = запись в `CONTENT_RESOURCES` + автоматически те же `ContentListPage` / `ContentEditPage` (через `pages/content/index.jsx`).
Контраст с `/promotions`: там отдельный файл страницы и `apiStock.js` на домен. Здесь — **один** список и **одна** форма на все ресурсы.
## Файлы проекта
| Файл | Назначение |
| --- | --- |
| `src/config/contentResources.js` | Описание 6 ресурсов: `slug`, `basePath`, колонки списка, поля формы |
| `src/api/apiContent.js` | RTK Query: `injectEndpoints` × 6, экспорт `contentHooks` |
| `src/pages/content/ContentListPage.jsx` | Универсальная страница списка |
| `src/pages/content/ContentEditPage.jsx` | Универсальная форма create/edit + виджеты полей |
| `src/pages/content/index.jsx` | Связывает ресурс → `NewsListPage`, `NewsEditPage`, … |
| `src/utils/parseSaveError.js` | Разбор ошибок API → `fieldErrors` / `globalMessage` |
| `src/styles/theme-override.scss` | Классы `.content-field--has-error`, `.content-field-error-msg` |
| `src/App.jsx` | 18 маршрутов (6 × list/create/edit) |
| `src/components/Sidebar/Sidebar.jsx` | Пункты меню |
| `src/components/Navbar/Navbar.jsx` | Мобильное меню |
| `src/store/store.js` | `import '../api/apiContent'` |
| `src/config/api.js` | `VITE_API_BASE_URL` (локальная разработка) |
## Конфигурация ресурса (`contentResources.js`)
Каждый ресурс — объект в `CONTENT_RESOURCES`:
| Поле | Тип | Назначение |
| --- | --- | --- |
| `slug` | string | URL в админке (`news`, `site-promo`, …) |
| `basePath` | string | Префикс API (`/news`, `/promo`, …) |
| `title` | string | Заголовок списка |
| `titleSingle` | string | Подпись в форме («новость», «услугу») |
| `icon` | string | Font Awesome для меню (не используется в `ContentListPage`, только в Sidebar) |
| `listColumns` | array | Колонки таблицы списка |
| `fields` | array | Поля формы create/edit |
| `listUsesLimit` | boolean? | Только `article`: `limit` в query |
| `listUsesMeta` | boolean? | Только `article`: ответ с `meta` |
### Колонки списка (`listColumns`)
```javascript
{ key: 'id', label: 'ID' }
{ key: 'name', label: 'Название' }
{ key: 'active', label: 'Активно', format: 'bool' } // → «Да» / «Нет»
{ key: 'regionId', label: 'Регион' } // → имя из regionSlice
```
### Поля формы (`fields`)
Общий набор для большинства ресурсов (`baseContentFields`):
| `key` | `type` | Виджет |
| --- | --- | --- |
| `name` | `text` | input text |
| `active` | `checkbox` | checkbox |
| `regionId` | `region` | select регионов |
| `alias` | `text` | input text |
| `anons` | `html` | `TextEditor` |
| `content` | `html` | `TextEditor` |
Дополнительные поля задаются хелперами `text(key, label)` и `json(key, label)` — см. таблицу ресурсов ниже.
## Виджеты полей (форма)
Виджеты объявлены **внутри** `ContentEditPage.jsx` (не отдельные файлы в `components/`). Это осознанный «мини-движок формы» для контента.
### Вспомогательные компоненты
| Виджет | Назначение |
| --- | --- |
| `FieldHint` | Текст ошибки под полем: `<span class="content-field-error-msg">` |
| `fieldWrapperClass(hasError, extra)` | Собирает классы: `form-group`, `content-field--has-error`, `form-check` |
| `ContentField` | Рендер одного поля по `field.type` |
Атрибут `data-field-key={field.key}` на обёртке — для скролла к первому полю с ошибкой (`querySelector`).
### Типы полей (`field.type`)
| type | UI | Значение в state | Отправка в API |
| --- | --- | --- | --- |
| `text` | `<input type="text">` | string | string или `null` если пусто |
| `number` | `<input type="number">` | string в форме | `Number` или `null` |
| `checkbox` | `<input type="checkbox">` | boolean | boolean |
| `region` | `<select>` из `selectRegions` | string id | `Number(regionId)` или `null` |
| `html` | `TextEditor` | string HTML | string; props **`content`**, **`setContent`** |
| `json` | `<textarea class="font-monospace">` | string (pretty JSON) | `JSON.parse` → object/array или `null` |
При ошибке у поля:
- обёртка `content-field--has-error` (красная рамка, фон `#fff5f5`);
- label `content-field-error-label`;
- control с Bootstrap `is-invalid`;
- подпись `FieldHint`.
Стили в `theme-override.scss` (SB Admin перебивает обычный `.text-danger` на label).
### Сериализация формы
| Функция | Когда | Что делает |
| --- | --- | --- |
| `emptyFormFromConfig(fields)` | mount / create | Пустой объект формы |
| `itemToForm(item, fields)` | после `GET` detail | API → строки формы, JSON → formatted string |
| `formToPayload(form, fields)` | перед save | форма → тело PUT/POST; невалидный JSON → throw с `fieldKey` |
## Страницы
### `ContentListPage`
Пропсы: `{ config, hooks }`.
| Блок UI | Поведение |
| --- | --- |
| Заголовок | `config.title` |
| «Добавить» | `navigate(\`/${config.slug}/create\`)` |
| Поиск | `searchValue`, сброс страницы на 1 |
| Таблица | Колонки из `config.listColumns`, клик по строке → `expandedId` |
| Раскрытая строка | Кнопка «Редактировать» → `/${slug}/edit/${id}` |
| Пагинация | Серверная, `pagination` / `meta` |
| Загрузка / ошибка | `LoadingComponent` / `ErrorComponent` |
Хуки: `hooks.useListQuery({ search, page, perPage: 20 })`, `useOutsideClick` для сброса раскрытия.
### `ContentEditPage`
Пропсы: `{ config, hooks, isCreate }`.
| Режим | Загрузка данных | `id` |
| --- | --- | --- |
| create | не грузит detail | нет |
| edit | `hooks.useItemQuery(id)` | из `useParams()` |
Состояние:
| state | Назначение |
| --- | --- |
| `form` | все поля кроме отдельной логики |
| `fieldErrors` | `Record<fieldKey, message>` |
| `globalError` | строка для блока вверху формы |
| `isModalSuccess` | модалка успеха |
Оболочка: `EditElementForm` (кнопки Сохранить / Отмена / Удалить).
#### Валидация и ошибки (без alert на save)
1. **Клиент** (`validateClient`): если в `config.fields` есть `name` / `alias` / `regionId` — проверка на пустое; результат в `fieldErrors`, **без** `window.alert`.
2. **JSON** в `formToPayload`: catch → `fieldErrors[fieldKey]` или `globalError`.
3. **API** (`parseSaveError`): Symfony `violations` → ключи полей; иначе `globalError`.
Вверху формы при ошибках:
- `alert alert-danger` с `globalMessage`;
- список полей с ошибками (дублирует подсказки у полей, удобно для длинных форм).
При появлении ошибок — smooth scroll к форме и к первому `[data-field-key]`.
Успех: `Modal` «Изменения внесены», таймер 2 с, затем refetch (edit) или переход на edit (create).
Удаление: `window.confirm` (единственный системный диалог); ошибка удаления — через `parseSaveError` / `globalError`, не alert.
### `pages/content/index.jsx`
Фабрика `bind(resourceKey)`:
```javascript
ListPage: () => <ContentListPage config={config} hooks={hooks} />
EditPage: () => <ContentEditPage config={config} hooks={hooks} isCreate={false} />
CreatePage: () => <ContentEditPage config={config} hooks={hooks} isCreate />
```
Экспортирует именованные компоненты для `App.jsx`: `NewsListPage`, `NewsEditPage`, `NewsCreatePage`, …
## Слой API (`apiContent.js`)
Для каждого ключа `CONTENT_RESOURCES` вызывается `injectResource(resourceKey)`:
| Endpoint name | Hook | Метод |
| --- | --- | --- |
| `get{Resource}List` | `useGetNewsListQuery`, … | GET list |
| `get{Resource}Item` | `useGetNewsItemQuery`, … | GET one |
| `create{Resource}` | `useCreateNewsMutation`, … | POST |
| `update{Resource}` | `useUpdateNewsMutation`, … | PUT |
| `delete{Resource}` | `useDeleteNewsMutation`, … | DELETE |
Имя ресурса в PascalCase: `news` → `News`, `medical-center` → `MedicalCenter`, `site-services` → `SiteServices`.
Публичный объект:
```javascript
export const contentHooks = {
news: { useListQuery, useItemQuery, useCreateMutation, useUpdateMutation, useDeleteMutation },
// promo, disease, medical-center, article, site-services
}
```
`refetchOnMountOrArgChange: true`, `keepUnusedDataFor: 0` на list — как у акций.
## Переиспользуемые компоненты (не контент-специфичные)
| Компонент | Роль в CRUD |
| --- | --- |
| `EditElementForm` | Карточка: заголовок, children, Сохранить / Отмена / Удалить |
| `LoadingComponent` | Загрузка list/detail |
| `ErrorComponent` | Ошибка запроса |
| `NotFindElement` | Запись не найдена (edit) |
| `TextEditor` | HTML-поля `anons`, `content` |
| `Modal` | Успешное сохранение |
| `useOutsideClick` | Закрытие раскрытой строки в таблице |
## Навигация
Пункты в `Sidebar.jsx` и `Navbar.jsx` (порядок может отличаться):
| label | `to` |
| --- | --- |
| Новости | `/news` |
| Промо (контент) | `/site-promo` |
| Заболевания | `/disease` |
| Медцентры | `/medical-center` |
| Статьи | `/article` |
| Услуги сайта | `/site-services` |
Все маршруты — дочерние к `MainPage` + `ProtectedRoute` (нужен token в `localStorage`).
## Поля по ресурсам (кратко)
Помимо `baseContentFields`, в конфиге заданы доп. поля:
| Ресурс | Особенности списка | Доп. поля (типы) |
| --- | --- | --- |
| news | + region | text: shortName, linkElPrice, timer, timerBg; json: formOrder, linkServices, linkStaff, photos |
| promo | + region | text: shortName, period, timer, timerBg; json: clinics, linkServices, linkStaff, photos |
| disease | без region в таблице | text + checkbox hidePicture; json: tags, staffList, linkFaq, … |
| medical-center | без region | много text + json (doctors, services, plusList, …) |
| article | `listUsesMeta` + `listUsesLimit` | text previewPicture; json doctors, services |
| site-services | без region | много text + json (faq, quiz, photos, …) |
Полный список — в `src/config/contentResources.js`.
## `parseSaveError`
Файл: `src/utils/parseSaveError.js`.
Обрабатывает:
- клиентский throw с `fieldKey` (JSON);
- RTK Query `err.data` — массив Symfony violations или объект с `violations`;
- строковый JSON в `data`;
- десериализацию Symfony (`propertyPath`, `message`);
- уникальные constraint (duplicate key) → привязка к `name` / `alias` по тексту.
Возвращает `{ fieldErrors, globalMessage }`. Если есть полевые ошибки, `globalMessage` обычно `null`.
## Как добавить седьмой ресурс
1. Backend: architecture + resource-ветка ([инструкция](/apps/backend-content-crud#как-добавить-новый-ресурс-по-образцу-news)).
2. В `CONTENT_RESOURCES` добавить объект с `slug`, `basePath`, `listColumns`, `fields`.
3. Ключ объекта автоматически подхватится в `apiContent.js` (`injectResource`) и в `index.jsx` (`bind`).
4. В `App.jsx` — три `Route` (list, create, edit/:id).
5. В `Sidebar` / `Navbar` — ссылка `to: '/your-slug'`.
Отдельные JSX-страницы для ресурса **не нужны**.
## Локальный запуск
```bash
make local-up
```
| Сервис | URL |
| --- | --- |
| Админка | http://localhost:3211/login |
| Backend API | http://localhost:8081 |
Логин: `local.backend@example.test` / `local-password` (`ROLE_ADMIN`).
`apps/adminPanel/.env.local`:
```env
VITE_API_BASE_URL=http://localhost:8081
```
Backend должен быть с поднятым content CRUD (`issues/27` или feature-ветки).
## Проверка
1. Войти под админом.
2. Для каждого из 6 пунктов меню: список, поиск, пагинация, create, edit, delete.
3. Сохранить с пустым `name` — красная подсветка, **без** alert.
4. Невалидный JSON в textarea — ошибка на поле.
5. Успешное сохранение — модалка, без alert.
6. Сравнить с backend: те же поля уходят в PUT/POST, что в [serializer groups](/apps/backend-content-crud).
## Сравнение с `/promotions`
| | Контент (`issues/27-future`) | Акции (`/promotions`) |
| --- | --- | --- |
| Страницы | 2 общие + конфиг | 3 отдельных файла |
| API | 1 `apiContent.js` | 1 `apiStock.js` |
| Поля формы | `ContentField` по типу | JSX вручную в `EditStockPage` |
| Ошибки save | `parseSaveError` + красные блоки | `text-danger` + `window.alert` |
| Успех | `Modal` | `Modal` |
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |
+183
View File
@@ -0,0 +1,183 @@
# adminPanel: архитектура и соглашения
`apps/adminPanel` — SPA на **React 18 + Vite** для внутренних операторов (контент, врачи, филиалы, акции). Локально поднимается контейнером `adminPanel-local` (`make local-up`), порт по умолчанию **3211**.
Связанные страницы:
- [CRUD контента в UI](/apps/admin-panel-content-crud) — новости, промо, заболевания и т.д.
- [Backend CRUD контента](/apps/backend-content-crud) — API, на которое смотрит админка.
## Стек
| Слой | Технология |
| --- | --- |
| UI | React, React Router 6 |
| Состояние | Redux Toolkit |
| API | RTK Query (`createApi` + `injectEndpoints`) |
| Стили | Bootstrap 4 (SB Admin 2), SCSS-переопределения |
| Редактор HTML | Jodit (`TextEditor`) |
| Сборка | Vite, ESLint |
Переменные окружения: `apps/adminPanel/.env.local``VITE_API_BASE_URL` (локально `http://localhost:8081`).
## Структура каталогов
```text
apps/adminPanel/src/
├── api/ # RTK Query: apiSlice + injectEndpoints по доменам
├── components/ # Переиспользуемые UI-блоки
├── config/ # api.js (base URL), при необходимости
├── hooks/ # useSpecialist, useSorting, useSortedPaginated, …
├── pages/ # Экраны (маршруты), в т.ч. *ListPage / Edit* / Add* по доменам
├── routes/ # ProtectedRoute
├── store/ # Redux store, slices (auth, region, utils)
└── styles/ # theme-override.scss
```
## Поток данных
```mermaid
flowchart LR
Page[pages/*] --> RTK[api/*.js]
RTK --> Slice[apiSlice baseQuery]
Slice --> Backend[Symfony API]
Page --> Redux[store/slice]
Redux --> Page
```
- **Чтение списков** — `useXxxQuery` с `refetchOnMountOrArgChange` там, где нужен свежий список.
- **Запись** — `useXxxMutation` + `authHeader()` (Bearer из `localStorage.token`).
- **Регионы** — статический справочник в `store/slice/regionSlice.js` (91–94). Филиалы — с API (`apiFilial`).
## Аутентификация
- `POST /user/login` → токен в `localStorage` (`apiSlice` login mutation).
- `ProtectedRoute` оборачивает layout с `MainPage`.
- Write-запросы: `authHeader()` в mutations.
Локальный админ: `local.backend@example.test` / `local-password` (`ROLE_ADMIN`).
## Layout и навигация
| Компонент | Назначение |
| --- | --- |
| `MainPage` | Shell: sidebar + navbar + `<Outlet />` |
| `Sidebar` / `Navbar` | Пункты меню (`SidebarNavItem`) |
| `ProtectedRoute` | Редирект на `/login` без токена |
Новый раздел: добавить `Route` в `App.jsx` и ссылку в `Sidebar.jsx` + `Navbar.jsx`.
## Переиспользуемые компоненты (обязательно брать готовые)
### Формы и списки
| Компонент | Когда использовать |
| --- | --- |
| `EditElementForm` | Любая карточка редактирования: заголовок, «Сохранить», «Отмена», опционально «Удалить» |
| `LoadingComponent` | Загрузка данных |
| `ErrorComponent` | Ошибка загрузки |
| `NotFindElement` | 404 по id |
| `THead` / `TBody` | Таблицы с сортировкой и раскрытием строки |
| `PageNav` | Пагинация (клиентская или после нормализации meta) |
| `FilterBar` | Фильтр списка врачей: регион + филиал + поиск |
### Модалки
| Компонент | Когда использовать |
| --- | --- |
| `Modal` | Универсальная модалка (portal, backdrop). **Успех сохранения** — как в `EditStockPage` |
| `ResponseModals` | loading / error / success для длинных операций |
| `DcodeModal`, `KodoperModal`, `StockModal` | Привязка расписания / кодов / акций к врачу |
Не использовать `window.alert` для успешного сохранения — только `Modal` с текстом «Изменения внесены».
### Редакторы и ввод
| Компонент | Когда использовать |
| --- | --- |
| `TextEditor` | HTML-поля (`content`, `anons`). Props: **`content`**, **`setContent`** (не `value`/`onChange`) |
| `TagInput`, `TagStaticInput`, `TagKodoperStatic` | Теги, коды операций |
| `PhoneInput` | Телефон |
### Доменные блоки (врач)
| Компонент | Назначение |
| --- | --- |
| `CertificatesForm`, `PortfolioForm`, `StocksForm` | Вкладки на карточке врача |
## Паттерны страниц
### Список (legacy, богатый UI)
Пример: `StoksListPage`, `SpecialistListPage`, `FilialsListPage`.
- локальный state: поиск, страница, `expandedId`;
- `useOutsideClick` по таблице;
- кнопка «Добавить» → `navigate('.../create')`.
### Контент CRUD (6 сущностей)
Рекомендуемая реализация (**`issues/27-future`**): общие `ContentListPage` / `ContentEditPage`, конфиг `contentResources.js`, виджеты `ContentField`, ошибки через `parseSaveError` и классы `content-field--has-error` (без `window.alert` при сохранении).
Альтернатива на `issues/27`: отдельные страницы по образцу `/promotions`.
Подробно (маршруты, виджеты, API, поля): [admin-panel-content-crud](/apps/admin-panel-content-crud).
### Редактирование (прочие домены)
`EditStockPage` / `EditSpecialistPage` — отдельная страница под домен, `EditElementForm`, при необходимости `Modal` на успех.
## API-слой
| Файл | Ресурс |
| --- | --- |
| `apiSlice.js` | `createApi`, login/logout, `authHeader` |
| `apiSpecialist.js` | Врачи |
| `apiStock.js` | Акции (`/promotions` → stock) |
| `apiFilial.js` | Филиалы |
| `apiContent.js` | Контент (6 ресурсов, `contentHooks`) |
| `apiDepartment.js`, `apiLocation.js`, … | Остальные домены |
Новый домен: `API.injectEndpoints({ endpoints: (build) => ({ ... }) })`, зарегистрировать reducer в `store.js` (если отдельный slice не нужен — достаточно `apiSlice`).
## Redux
| Slice | Содержимое |
| --- | --- |
| `auth` | token, user (login matchers) |
| `region` | `regions: { 91: 'Саратов', … }` |
| `utils` | `ITEMS_PER_PAGE`, конфиг колонок таблиц |
## Хуки
| Хук | Назначение |
| --- | --- |
| `useSortedPaginated` | Сортировка + slice для клиентской пагинации |
| `useSorting` | state сортировки для `THead` |
| `useOutsideClick` | Закрыть expanded row / dropdown |
| `useSpecialist` | Данные врача + filials + mutations |
## Чего избегать
- Дублировать разметку карточки вместо `EditElementForm`.
- Подключать `TextEditor` с неверными props — контент не сохранится или упадёт на blur.
- Хардкодить URL API в новых экранах — выносить в `config/api.js` / `apiUrl()` отдельной задачей.
- Делать generic-обёртки для контента вместо копирования паттерна `StoksListPage` / `EditStockPage`.
## Локальный запуск
```bash
make local-up
open http://localhost:3211/login
```
Пересборка в контейнере: `docker exec adminPanel-local yarn build`.
## Ветки Git
| Ветка | Содержание |
| --- | --- |
| `dev` | production-like база |
| `issues/27-future` | контент CRUD: generic-страницы + виджеты полей (см. [документацию](/apps/admin-panel-content-crud)) |
| `issues/27` | контент CRUD: копия паттерна `/promotions` |
| Backend | [backend-content-crud](/apps/backend-content-crud) — `feature/content-crud-architecture`, `feature/content-crud-*`; `issues/27` не трогать |
+173
View File
@@ -0,0 +1,173 @@
# Backend: архитектура модулей
`apps/backend` - новый API-слой и единое хранилище данных на Symfony 7.3. Приложение обслуживается контейнером `php84`, nginx направляет домен `api.sovamed.ru` в `apps/backend/public/index.php`.
## Слои
```mermaid
flowchart TB
controllers[Controller\n25 классов]
dto[Dto\n12 классов]
services[Service\n50 классов]
repositories[Repository\n26 классов]
entities[Entity\n26 классов]
commands[Command\n13 классов]
messages[Message / Handler\n3 async-сценария]
external[Внешние системы\nBitrix, Infoclinica, SMS, Calltouch, SmartCaptcha]
db[(PostgreSQL)]
mysql[(Bitrix MySQL)]
cabinet[(Cabinet PostgreSQL)]
redis[(Redis)]
controllers --> dto
controllers --> services
services --> repositories
repositories --> entities
repositories --> db
services --> external
services --> redis
commands --> services
messages --> services
services --> mysql
services --> cabinet
```
## Контроллеры и зоны ответственности
### Пользователь и авторизация
- `UserController` - `/user`: логин, logout, текущий пользователь, смена региона, регистрация/авторизация по UID или pcode.
- Использует `AuthenticationService`, `RegistrationService`, `UserProfileService`, `JWTDecoderService`.
- Авторизация stateless через JWT (`lexik/jwt-authentication-bundle`), пользователь ищется по `User.email`.
```mermaid
sequenceDiagram
participant Client
participant UserController
participant Validator
participant Auth as AuthenticationService
participant JWT as JWTTokenManager
participant DB as UserRepository
Client->>UserController: POST /user/login
UserController->>Validator: UserLoginDto
UserController->>Auth: jsonAuth(dto)
Auth->>DB: поиск User по email
DB-->>Auth: User
Auth-->>UserController: user + isPasswordValid
UserController->>JWT: create(user)
UserController-->>Client: token + user
```
### Справочники и CMS-контент
- `ArticleController` - статьи.
- `DiseaseController` - заболевания.
- `MedicalCenterController` - медицинские центры.
- `NewsController` - новости.
- `PromoController` - акции.
- `SiteServiceController` - услуги сайта.
- `StockController` - акции/предложения с привязкой к врачам.
Типовой CRUD-поток:
```mermaid
flowchart LR
request[HTTP request] --> controller[CRUD Controller]
controller --> crud[Crud Service\ncreate/update/delete/sync]
crud --> repository[Repository]
repository --> entity[Entity]
entity --> db[(PostgreSQL)]
```
### Врачи, филиалы, расписание и цены
- `SpecialistController` - врачи, карточка врача, фильтрация, фото, расписание, запись.
- `DepartmentController` - отделения.
- `FilialController` - филиалы, поиск по региону, фото.
- `LocationController` - локации врача: отделение, филиал, online mode, ближайшая дата.
- `PriceListController` и `PriceDepartmentController` - цены и группы цен.
- `SpecialistDocsController` - документы/сертификаты врача.
- `SpecialistDcodeDescriptionController` - описания врача по `dcode`.
- `WebGetDocinfoController` - данные врачей из внешнего представления/источника.
Ключевая логика находится в:
- `SpecialistService` - список, карточка, расписание, создание анонимной записи, загрузка фото.
- `ScheduleCacheService` - кеш расписания.
- `ScheduleErrorHandlerService` - нормализация ошибок расписания.
- `PriceListService`, `DepartmentService`, `FilialService`, `LocationService` - чтение справочников.
### Интеграции и служебные endpoints
- `InfoclinicaController` - прокси/интеграция с MIS: расписание, врачи, anonymous reserve.
- `CalltouchController` - создание лида.
- `ServiceController` - отправка email и SmartCaptcha.
- `XmlFeedController` - XML-фиды для Яндекса.
- `HelperController` - вспомогательные методы, например склонение лет.
- `UsrlogController` - список пользовательских логов.
## Сервисы
### HTTP-клиенты
- `AbstractHttpClientService` - общая обертка над HTTP-клиентом, cookies, request.
- `InfoclinicaClientService` - расписание, филиалы, регистрация, anonymous reserve.
- `BitrixClientService` - получение изображения специалиста.
- `CalltouchClientService` - создание заявки.
- `SmartCaptchaClientService` - проверка captcha.
- `Sms4bClientService`, `SmsruClientService` - отправка SMS, отправители, баланс.
### Доменная логика
- `SpecialistService` - врач как центральный доменный объект: расписание, карточка, список, фото, запись.
- `RegistrationService` и `AuthenticationService` - создание пользователя и проверка авторизации.
- `UserProfileService` - изменение профиля пользователя.
- `DiseaseCrudService`, `MedicalCenterCrudService`, `NewsCrudService`, `PromoCrudService`, `SiteServiceCrudService` - CRUD и синхронизация контента из внешних представлений.
- `SequenceService` - синхронизация PostgreSQL sequence после импортов.
- `FileUploaderService`, `ImageService` - файлы и изображения.
- `XmlFeedGeneratorService`, `XmlFeedGeneratorV1Service` - генерация XML-фидов.
### Инфраструктурные сервисы
- `AESCryptService` - шифрование/дешифрование AES.
- `JWTDecoderService` - получение текущего пользователя из JWT.
- `TransliteService` - транслитерация.
- `PerformanceTrackerService` - замер длительности операций.
- `SendMailService`, `SendMailConfig` - отправка почты.
## Консольные команды
```mermaid
flowchart TB
cron[Cron / ручной запуск] --> command[Symfony Command]
command --> bitrix[BitrixService]
command --> crud[Crud/Domain Service]
command --> repo[Repository]
repo --> db[(PostgreSQL)]
```
Команды в `src/Command` синхронизируют врачей, отзывы, отделения, заболевания, филиалы, медцентры, новости, цены, акции и услуги. Отдельная команда `ClearScheduleCacheCommand` чистит кеш расписания.
## Асинхронные сообщения
- `GetScheduleMessage` / `GetScheduleMessageHandler` - получение расписания.
- `GetSpecialistPictureMessage` / handler - загрузка изображения специалиста.
- `GetAnonymousReserveRequestMessage` / handler - обработка анонимной записи.
## Главные доменные сущности
- `Specialist` - врач: имя, фото, активность, регион, alias, должность, опыт, расписание, связи с `Location`, `Review`, `SpecialistDocs`, `Stock`.
- `Location` - привязка врача к отделению/филиалу и режиму приема.
- `Schedule` - слот расписания: `dcode`, отделение, филиал, дата, кабинет, интервал, цена, свободность.
- `User` - пользователь API: UID, email, роли, регион, пароль, дата рождения, время входа.
- `Record` - запись пациента: врач, телефон, дата создания, hash, payload `reserve`.
- `PriceList` и `PriceDepartment` - цены и группы цен.
- `Filial`, `Department`, `MedicalCenter` - организационная структура.
- `Article`, `News`, `Promo`, `Disease`, `SiteService`, `Stock` - контент сайта.
Полная ER-схема вынесена на страницу [Модели данных](../data-model.md).
Детальная карта **ограниченных контекстов, сущностей, контроллеров и команд** — на странице [Backend: DDD / бизнес-сущности](./backend-ddd.md).
Пошаговые **бизнес-сценарии** (JWT, расписание, запись, синхронизация, фиды): [Backend: бизнес-сценарии](./backend-scenarios/index.md).
+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.
+263
View File
@@ -0,0 +1,263 @@
# Backend: бизнес-сущности и границы (DDD-обзор)
Страница описывает `apps/backend` с точки зрения **предметной области**: какие сущности относятся к одному смысловому блоку, какие HTTP-контроллеры и сервисы их обслуживают, и как устроены фоновые задачи. Это не «каноничное» DDD-приложение с явными bounded context в коде, а **карта домена поверх существующей Symfony-структуры** (`Entity`, `Repository`, `Controller`, `Service`, `Command`, `Message`).
См. также: [архитектура модулей](./backend-architecture.md), [бизнес-сценарии по потокам](./backend-scenarios/index.md), [CRUD контента](./backend-content-crud.md), [модели данных](../data-model.md), [потоки данных](../flows.md).
## Карта контекстов
```mermaid
flowchart TB
subgraph identity[Идентичность и доступ]
User[User]
end
subgraph org[Организация клиники]
Filial[Filial]
Department[Department]
MedicalCenter[MedicalCenter]
end
subgraph staff[Врач, локации, расписание]
Specialist[Specialist]
Location[Location]
Schedule[Schedule]
Docs[SpecialistDocs]
DcodeDesc[SpecialistDcodeDescription]
Docinfo[WebGetDocinfo]
Idoctor[Idoctor]
end
subgraph booking[Запись и уведомления]
Record[Record]
AlertSms[AlertSms]
MarkKiosk[MarkKiosk]
end
subgraph pricing[Прайс]
PriceList[PriceList]
PriceDepartment[PriceDepartment]
end
subgraph content[Контент сайта]
Article[Article]
News[News]
Promo[Promo]
Disease[Disease]
SiteService[SiteService]
Stock[Stock]
end
subgraph reputation[Отзывы]
Review[Review]
end
subgraph integrations[Интеграции и утилиты]
Calltouch[Calltouch API]
XmlFeed[XML фиды]
MailCaptcha[Почта / SmartCaptcha]
end
Specialist --> Location
Specialist --> Review
Specialist --> Docs
Specialist --> DcodeDesc
Record --> AlertSms
PriceList --> PriceDepartment
```
---
## 1. Идентичность и доступ
**Смысл:** учётная запись пользователя API, вход по паролю, JWT, регистрация, смена региона, сценарии с UID/pcode.
| Роль в DDD | Артефакт | Путь / класс |
| --- | --- | --- |
| Агрегат / сущность | `User` | `src/Entity/User.php` |
| Репозиторий | `UserRepository` | `src/Repository/UserRepository.php` |
| Входная точка API | `UserController` | префикс маршрута `/user` |
| Доменные сервисы | `AuthenticationService`, `RegistrationService`, `UserProfileService` | `src/Service/User/` |
| Инфраструктура | `JWTDecoderService`, Lexik JWT | `src/Service/DecoderJWT/` |
| DTO | `UserLoginDto`, `RegistrationDto`, `UserAuthDto`, `UserUidAuthDto`, `RegionDto` | `src/Dto/` |
Консольных команд импорта для `User` в текущем дереве нет: пользователи создаются через API и админские сценарии.
---
## 2. Организация клиники (филиалы, отделения, медцентры)
**Смысл:** где оказываются услуги, структура сети, справочники для сайта и записи.
| Сущность | Репозиторий | Контроллер (префикс) | Сервисы | Команды синхронизации |
| --- | --- | --- | --- | --- |
| `Filial` | `FilialRepository` | `FilialController``/filial` | `FilialService` | `UploadFilialsCommand` |
| `Department` | `DepartmentRepository` | `DepartmentController``/department` | `DepartmentService` | `UploadDepartmentsCommand` |
| `MedicalCenter` | `MedicalCenterRepository` | `MedicalCenterController``/medical-center` | `MedicalCenterCrudService` | `UploadMedicalCentersCommand` |
---
## 3. Врач, локации приёма, расписание и материалы
**Смысл:** центральный домен — **врач** (`Specialist`), его **локации** (`Location`: отделение, филиал, online, ближайшая дата), **слоты расписания** (`Schedule`), справочные тексты и медиа.
| Сущность | Репозиторий | Где в API | Основная логика |
| --- | --- | --- | --- |
| `Specialist` | `SpecialistRepository` | `SpecialistController``/specialist` | `SpecialistService` (список, карточка, расписание, фото, запись, интеграция с MIS) |
| `Location` | `LocationRepository` | `LocationController` (admin) — пути вида `/specialist/{id}/location/...`, `/locations/empty` | `LocationService`, `EntityManager` в контроллере |
| `Schedule` | `ScheduleRepository` | косвенно через `SpecialistService`, кеш | `ScheduleCacheService`, `ScheduleErrorHandlerService` |
| `SpecialistDocs` | `SpecialistDocsRepository` | `SpecialistDocsController``/specialist-docs/...` | загрузка файлов, `ImageService`, `FileUploaderService` |
| `SpecialistDcodeDescription` | `SpecialistDcodeDescriptionRepository` | `SpecialistDcodeDescriptionController``/specialist-dcode-description/...` | пагинация, CRUD |
| `WebGetDocinfo` | `WebGetDocinfoRepository` | `WebGetDocinfoController``/docinfo` | чтение внешнего/legacy-представления врача |
| `Idoctor` | `IdoctorRepository` | `InfoclinicaController``/idoctor/list` | фильтрация, пагинация |
**Асинхронные сообщения (Messenger):**
- `GetScheduleMessage``GetScheduleMessageHandler`
- `GetSpecialistPictureMessage``GetSpecialistPictureMessageHandler`
- `GetAnonymousReserveRequestMessage``GetAnonymousReserveRequestMessageHandler`
**Команды:** `UploadDoctorsCommand`, `BitrixUpdateDoctorsCommand`, `ClearScheduleCacheCommand`.
**Внешние системы в этом контексте:** Infoclinica/MIS (`InfoclinicaClientService`), Bitrix (`BitrixClientService`, `BitrixService`) для врачей и медиа.
---
## 4. Запись пациента, отметки киоска, SMS
**Смысл:** факт записи, уведомление по SMS, отметка прохождения для киоска.
| Сущность | Репозиторий / доступ | Где используется |
| --- | --- | --- |
| `Record` | `RecordRepository` | создание/учёт записи через `SpecialistService` и сценарии записи |
| `AlertSms` | `AlertSmsRepository` | связь записи с ответом SMS-провайдера (`Record``AlertSms`) |
| `MarkKiosk` | `EntityManager::getRepository(MarkKiosk::class)` | `InfoclinicaController::clvisitsovacheckpass` — отметка по `pcode` и филиалу |
HTTP: `POST /reservation/anonymous-reserve` (`InfoclinicaController`) — анонимная запись; проверка киоска — `GET /infoclinica/clvisitsovacheckpass/{filial}` (требуется `ROLE_USER`).
SMS-клиенты: `Sms4bClientService`, `SmsruClientService`.
---
## 5. Прейскурант
| Сущность | Репозиторий | Контроллер | Сервис | Команды |
| --- | --- | --- | --- | --- |
| `PriceList` | `PriceListRepository` | `PriceListController` — префикс `/pricelist`, список `/list` | `PriceListService` | `UploadPriceCommand` |
| `PriceDepartment` | `PriceDepartmentRepository` | `PriceDepartmentController` — тот же префикс `/pricelist`, эндпоинт `/department` | через репозиторий и сервисы фидов | `UploadPriceDepCommand` |
`XmlFeedController` использует `PriceListService` и филиал для генерации фидов — см. контекст интеграций.
---
## 6. Контент сайта (статьи, новости, акции, услуги, заболевания, акции у врачей)
| Сущность | Репозиторий | Контроллер | Паттерн |
| --- | --- | --- | --- |
| `Article` | `ArticleRepository` | `ArticleController``/article` | `CrudResponder` + `Paginator` + `ContentFilterDto` ([описание CRUD](./backend-content-crud.md)) |
| `News` | `NewsRepository` | `NewsController``/news` | `NewsCrudService` + типовой CRUD |
| `Promo` | `PromoRepository` | `PromoController``/promo` | `PromoCrudService` |
| `Disease` | `DiseaseRepository` | `DiseaseController``/disease` | `DiseaseCrudService` |
| `SiteService` | `SiteServiceRepository` | `SiteServiceController``/site-services` | `SiteServiceCrudService` |
| `Stock` | `StockRepository` | `StockController``/stock/...` | CRUD, изображения, связь Many-to-Many с `Specialist` |
**Команды:** `UploadNewsCommand`, `UploadPromoCommand`, `UploadDiseasesCommand`, `UploadSiteServicesCommand` (остальные сущности этого блока подтягиваются согласно фактическим именам команд в `src/Command`).
---
## 7. Отзывы
| Сущность | Репозиторий | Контроллер | Прочее |
| --- | --- | --- | --- |
| `Review` | `ReviewRepository` | `ReviewController``/review` | список с фильтрами, создание от авторизованного пользователя, CRUD для админа |
**Команда:** `BitrixUpdateReviewsCommand` — синхронизация с Bitrix.
---
## 8. Интеграции и публичные утилиты
| Назначение | Контроллер / вход | Сервисы |
| --- | --- | --- |
| Лиды Calltouch | `CalltouchController``/calltouch/create-lead` | `CalltouchClientService` |
| XML для Яндекса | `XmlFeedController``/xml/feed` | `XmlFeedGeneratorService`, `XmlFeedGeneratorV1Service`, данные врачей/цен/филиалов |
| Отправка почты + captcha | `ServiceController``/service/sendmail` | `SendMailService`, `SmartCaptchaClientService` |
| Мелкие helper | `HelperController``/helper/text-year` | `HelperService` |
| Заглушка | `DefaultController` | `GET /` |
---
## 9. Аудит: логи пользователей (legacy БД)
| Назначение | Контроллер | Реализация |
| --- | --- | --- |
| Список записей `usrlog` | `UsrlogController``/usrlog/list` | DBAL-подключение `doctrine.dbal.cabinet_connection`, роль `ROLE_LOGS` |
Это **интеграционный контекст**: сущность в `apps/backend/src/Entity` не выделяется, данные читаются из базы cabinet.
---
## 10. Сущности без выделенного HTTP-слоя в текущем API
Их таблицы и Doctrine-модели есть, репозитории сгенерированы, но **отдельного REST-контроллера в `src/Controller` нет** (на момент описания документа):
| Сущность | Репозиторий | Зачем полезно знать |
| --- | --- | --- |
| `Banner` | `BannerRepository` | баннеры; возможна выдача через другие сервисы или будущие эндпоинты |
| `WidgetForm`, `WidgetFormInput` | `WidgetFormRepository`, `WidgetFormInputRepository` | Symfony Forms `WidgetFormType`, `WidgetFormInputType` — конструктор полей, данные в БД |
При появлении новых маршрутов их логично отнести к контексту **«виджеты и формы»** или **«маркетинг»**.
---
## Сводная таблица: контроллер → базовый префикс
| Контроллер | Префикс или примечание |
| --- | --- |
| `ArticleController` | `/article` |
| `CalltouchController` | `/calltouch` |
| `DefaultController` | `/` |
| `DepartmentController` | `/department` |
| `DiseaseController` | `/disease` |
| `FilialController` | `/filial` |
| `HelperController` | `/helper` |
| `InfoclinicaController` | без классового префикса: `/infoclinica/...`, `/idoctor/...`, `/reservation/...` |
| `LocationController` | без префикса: `/locations/...`, `/specialist/{id}/location/...` |
| `MedicalCenterController` | `/medical-center` |
| `NewsController` | `/news` |
| `PriceDepartmentController` | `/pricelist` |
| `PriceListController` | `/pricelist` |
| `PromoController` | `/promo` |
| `ReviewController` | `/review` |
| `ServiceController` | `/service/...` |
| `SiteServiceController` | `/site-services` |
| `SpecialistController` | `/specialist` |
| `SpecialistDcodeDescriptionController` | `/specialist-dcode-description/...` |
| `SpecialistDocsController` | `/specialist-docs/...` |
| `StockController` | `/stock/...` |
| `UserController` | `/user` |
| `UsrlogController` | `/usrlog/...` |
| `WebGetDocinfoController` | `/docinfo` |
| `XmlFeedController` | `/xml/feed` |
---
## Консольные команды по доменам
| Команда (класс) | Домен |
| --- | --- |
| `UploadDoctorsCommand` | Врачи |
| `BitrixUpdateDoctorsCommand` | Врачи (Bitrix) |
| `BitrixUpdateReviewsCommand` | Отзывы |
| `UploadDepartmentsCommand` | Отделения |
| `UploadDiseasesCommand` | Заболевания |
| `UploadFilialsCommand` | Филиалы |
| `UploadMedicalCentersCommand` | Медцентры |
| `UploadNewsCommand` | Новости |
| `UploadPriceCommand` | Цены |
| `UploadPriceDepCommand` | Группы цен |
| `UploadPromoCommand` | Акции |
| `UploadSiteServicesCommand` | Услуги сайта |
| `ClearScheduleCacheCommand` | Расписание (кеш) |
Точные имена команд Symfony: `docker exec -it php84 php bin/console list app` (или локальный `php bin/console` из `apps/backend`).
+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).
+136
View File
@@ -0,0 +1,136 @@
# Backend API
`apps/backend` - новое backend API на Symfony 7.3. Оно выступает единым хранилищем данных и публичным API для клиентских приложений.
## Где смотреть код
| Зона | Путь | Что внутри |
| --- | --- | --- |
| Контроллеры | `apps/backend/src/Controller` | HTTP-эндпоинты, маршруты, security-атрибуты |
| Сущности | `apps/backend/src/Entity` | Doctrine ORM mapping, serializer groups |
| Репозитории | `apps/backend/src/Repository` | QueryBuilder, фильтры, поиск |
| Сервисы | `apps/backend/src/Service` | бизнес-логика, интеграции, общие helpers |
| Команды | `apps/backend/src/Command` | консольные sync/import задачи |
| Конфиг | `apps/backend/config` | security, routes, Nelmio/Swagger, Doctrine |
## Основные разделы документации
- [DDD-обзор и бизнес-сущности](./backend-ddd.md) - контексты предметной области, связь сущностей с контроллерами, сервисами и командами.
- [Бизнес-сценарии (use cases)](./backend-scenarios/index.md) - отдельные статьи по ключевым потокам: JWT, pcode, расписание, запись, синхронизация, фиды.
- [CRUD для контентных сущностей](./backend-content-crud.md) - подробный разбор свежей задачи: контроллеры, пагинация, `CrudResponder`, `ContentRepositoryFilter`, sync services.
- [API и Swagger](../api-routes.md) - список HTTP-методов и где открыть Swagger.
- [Потоки данных](../flows.md) - запуск, синхронизация, авторизация, расписание.
- [Модели данных](../data-model.md) - ER-схемы и ключевые сущности.
## Архитектурный стиль backend
Для новых backend-задач стоит придерживаться таких правил:
- контроллеры должны быть тонкими и маршрутизировать запрос к сервисам/репозиториям;
- фильтрация списков должна жить в repository через `QueryBuilder`;
- пагинация должна использовать `Pagerfanta`, если список отдаётся наружу;
- write-поля должны контролироваться serializer groups (`*:write`);
- read-поля должны контролироваться serializer groups (`*:read`);
- admin-only операции должны быть закрыты через `#[IsGranted('ROLE_ADMIN')]`;
- sync/import из внешних источников не нужно смешивать с HTTP CRUD.
## Быстрая проверка backend
```bash
php bin/console cache:warmup --env=dev
php bin/console debug:router --env=dev
```
Публичный smoke-test списка:
```bash
curl 'http://localhost:8081/news/list?page=1&perPage=2'
```
Swagger UI:
```text
http://localhost:8081/docs
```
## Стек
- PHP `>=8.2`, контейнер `php84`.
- Symfony 7.3.
- Doctrine ORM 3, DBAL 4, migrations.
- PostgreSQL как основная БД.
- Дополнительные подключения к Bitrix MySQL и базе cabinet.
- Redis через `predis/predis`.
- JWT-аутентификация через `lexik/jwt-authentication-bundle`.
- OpenAPI-аннотации и `nelmio/api-doc-bundle`.
- Symfony Messenger/Scheduler для фоновой логики.
## Основные директории
- `src/Controller` - API-контроллеры.
- `src/Dto` - DTO для входных данных.
- `src/Entity` - Doctrine entities.
- `src/Repository` - запросы к БД.
- `src/Service` - бизнес-логика и интеграции.
- `src/Command` - CLI-команды импорта и синхронизации.
- `src/Message` и `src/MessageHandler` - асинхронные задачи.
- `migrations` - миграции БД.
## Точки входа
nginx направляет `api.sovamed.ru` в `apps/backend/public/index.php`, далее запросы обрабатываются Symfony router. Роуты объявлены PHP attributes в контроллерах.
Примеры групп контроллеров:
- `UserController` - логин, JWT, профиль пользователя, регистрация.
- `SpecialistController` - врачи и расписание.
- `PriceListController` и `PriceDepartmentController` - цены и категории.
- `ReviewController` - отзывы.
- `FilialController`, `DepartmentController`, `MedicalCenterController` - справочники клиник.
- `InfoclinicaController`, `CalltouchController` - интеграции.
## Авторизация
Security настроен как stateless API с JWT. Provider берет пользователя из `App\Entity\User` по email.
Для защищенных методов используются `#[IsGranted('ROLE_USER')]` и роли Symfony Security.
## Полезные команды
Установка зависимостей:
```bash
docker exec -it php84 composer install
```
Миграции:
```bash
docker exec -it php84 php bin/console doctrine:migrations:migrate
```
Очистка кеша:
```bash
docker exec -it php84 php bin/console cache:clear
```
Генерация swagger-файла, если установлены зависимости:
```bash
docker exec -it php84 composer generate-swagger
```
Запуск тестов:
```bash
docker exec -it php84 composer phpunit
```
## Служебные команды
В `src/Command` есть команды загрузки врачей, филиалов, новостей, услуг, цен, отзывов и кеша расписания. Названия Symfony-команд лучше смотреть через:
```bash
docker exec -it php84 php bin/console list app
```
+163
View File
@@ -0,0 +1,163 @@
# Cabinet: архитектура модулей
`apps/cabinet` - legacy-монолит личного кабинета на Symfony 5.4. Он обслуживается контейнером `php82`, nginx направляет `cabinet.sovamed.ru` в `apps/cabinet/public/index.php`.
## Слои
```mermaid
flowchart TB
browser[Browser]
controllers[Controller\n14 классов]
forms[Form\n19 классов]
services[Service\n4 класса]
bundles[Bundle\nлокальные интеграции]
repositories[Repository\n21 класс]
entities[Entity\n21 класс]
twig[Twig templates]
assets[Webpack Encore assets]
db[(PostgreSQL)]
bitrix[(Bitrix)]
mis[Infoclinica / MIS]
external[Calltouch, SMS, Notisend, Yandex Direct]
browser --> controllers
controllers --> forms
controllers --> services
controllers --> bundles
controllers --> repositories
repositories --> entities
repositories --> db
controllers --> twig
twig --> assets
services --> repositories
bundles --> bitrix
bundles --> mis
bundles --> external
```
## Контроллеры
### Пользовательский кабинет
- `SecurityController` - login/logout, регистрация, восстановление, настройки, платежи, история болезни, направления, refund, проверка авторизации API.
- `DefaultController` - главная кабинета, вызов врача на дом, стоимость услуг, админское обновление прайса, справка.
- `SpecialistController` - каталог врачей, карточка врача, онлайн-специалисты, избранное.
- `WidgetController` - справки, review source, проверка записи/виджета.
Поток защищенной страницы:
```mermaid
sequenceDiagram
participant User
participant Security as Security Firewall
participant Controller
participant Service
participant Repository
participant Twig
User->>Security: GET / или /payment
Security->>Controller: проверка сессии / ROLE_USER
Controller->>Service: подготовка данных
Service->>Repository: запросы к БД
Repository-->>Service: сущности
Service-->>Controller: данные
Controller->>Twig: render
Twig-->>User: HTML
```
### Админка и CMS
- `BannerController` - баннеры.
- `CategoryPageController` и `PageController` - категории и страницы.
- `DepartmentController` - отделения.
- `ReviewSourceController` - источники отзывов.
- `WidgetFormController`, `WidgetFormInputController` - конструктор форм виджетов.
Большинство таких контроллеров используют Symfony Forms и Doctrine repositories напрямую.
### API
- `PublicAPIController` - публичные методы `/api`: anonymous reserve, интервалы расписания, данные пользователя, прайс, врачи.
- `InternalAPIController` - swagger, captcha, баннеры, логирование, запись, сообщения, поиск, отделения.
- `CalltouchAPIController` - добавление заявки Calltouch.
## Локальные Bundle-интеграции
В `src/Bundle` находятся не Symfony bundle в классическом смысле, а локальные клиенты/утилиты:
- `Infoclinica\Client` и `Infoclinica\Rest` - низкоуровневый HTTP-клиент и набор методов MIS: записи, профиль, платежи, бонусы, направления, история болезни, login, reserve.
- `Bitrix\Request` - врачи, отзывы, изображения, филиалы, отделения, услуги.
- `Calltouch\Request` - заявки и выборки звонков/обращений.
- `Sms\Manager` и `Notisend\Request` - отправка сообщений.
- `Yandex\Direct` - отчеты и сущности Direct.
- `Crypt\AES` - AES-операции.
- `Helper\AmountInWords` - сумма прописью.
- `Utils\Logger` - логирование.
## Сервисы
- `PriceListService` - фильтрация и построение запросов прайса.
- `SpecialistService` - списки и карточки врачей.
- `SpecialistMoreService` - вычисляемая информация по врачу: локации, цены, отзывы, минимальная цена.
- `UserCleanupService` - очистка просроченных/неподтвержденных пользователей.
## Формы и Twig
```mermaid
flowchart LR
controller[Controller] --> form[Symfony Form]
form --> entity[Entity]
controller --> twig[Twig template]
twig --> encore[Webpack Encore assets]
encore --> public[public/build]
```
Формы лежат в `src/Form`; шаблоны - в `templates`; frontend-ассеты - в `assets`; сборка описана в `webpack.config.js` и `package.json`.
## Авторизация
Security использует `LoginFormAuthenticator`. Пользователь берется из `App\Entity\User` по email, пароль хешируется bcrypt. Logout удаляет cookies `CABINET_SESSION`, `WR_SESSION`, `WR_FLASH`, `PLAY_SESSION`, `WR_DETAIL`, `region`.
```mermaid
sequenceDiagram
participant User
participant Login as SecurityController
participant Auth as LoginFormAuthenticator
participant Provider as User Provider
participant Session
User->>Login: POST /login
Login->>Auth: credentials
Auth->>Provider: load user by email
Provider-->>Auth: User
Auth->>Session: создать сессию
Session-->>User: cookies
```
## Консольные команды
Команды в `src/Command` синхронизируют данные из Infoclinica/Bitrix, обновляют врачей и прайсы, сравнивают врачей, работают с Yandex Direct, AES и очисткой пользователей.
```mermaid
flowchart TB
cron[Cron / ручной запуск] --> cmd[Symfony Command]
cmd --> base[BaseCommand]
cmd --> bundles[Bundle clients]
cmd --> services[Services]
services --> repositories[Repositories]
repositories --> db[(PostgreSQL)]
```
## Доменные сущности
- `User` - пользователь кабинета: email, роли, пароль, UID, token, ФИО, телефон, флаг подтверждения, активность.
- `SpecialistView` - представление врача: имя, специальность, категория, опыт, описание, alias, `dcode`, регион, услуга, признак ДМС.
- `LocationView` - представление локации врача: `dcode`, отделение, филиал, режим online, ближайшая дата.
- `PriceList`, `PriceDepartment`, `Price` - прайс и группы.
- `Record` и `AlertSms` - запись и связанное SMS-уведомление.
- `City`, `Filial`, `ReviewSource`, `Banner` - региональная структура и источники отзывов.
- `CategoryPage`, `Page` - CMS-страницы.
- `WidgetForm`, `WidgetFormInput` - формы виджетов.
- `DirectCompany`, `DirectReport` - данные Яндекс Директа.
Полная ER-схема вынесена на страницу [Модели данных](../data-model.md).
+93
View File
@@ -0,0 +1,93 @@
# Cabinet
`apps/cabinet` - старый личный кабинет и административный монолит на Symfony 5.4.
## Стек
- Symfony 5.4.
- PHP в общем compose обслуживается контейнером `php82`.
- Twig-шаблоны.
- Webpack Encore для ассетов.
- Doctrine ORM 2 и migrations.
- PostgreSQL и отдельное подключение к Bitrix.
- Redis cache через Symfony cache pool.
- Авторизация через guard authenticator `App\Security\LoginFormAuthenticator`.
## Основные директории
- `src/Controller` - страницы кабинета, админка и публичный API.
- `src/Bundle` - локальные интеграционные обертки: Bitrix, Infoclinica, Calltouch, SMS, Notisend, Yandex.
- `src/Command` - синхронизации и служебные операции.
- `src/Entity` - Doctrine entities.
- `src/Form` - формы Symfony.
- `src/Service` - бизнес-логика.
- `templates` - Twig-шаблоны.
- `assets` - JS/CSS-ассеты для Webpack Encore.
## Расписание врачей
Публичный сайт (список врачей, слоты записи) получает **живое расписание** из Infoclinica через прокси `GET /api/interval`, а не из backend API. Batch-sync ближайших дат (`nearestDate`) идёт через backend cron → PostgreSQL views.
Полный разбор: [Расписание врачей: синхронизация и отображение](./doctor-schedule-sync.md).
## Онлайн-консультация
Запись на онлайн-приём: `/online-specialists` (требуется авторизация), слоты через `/api/interval?onlineMode=1`, бронирование через Infoclinica webSDK, оплата и видео в `/case-history`.
Подробно: [Онлайн-консультация в личном кабинете](./online-consultation.md).
Тесты: `make local-online-smoke`, `make cabinet-test`.
## Точки входа
nginx направляет `cabinet.sovamed.ru` в `apps/cabinet/public/index.php`. Корневой маршрут `/` обрабатывается `DefaultController::index` и требует `ROLE_USER`.
Важные контроллеры:
- `SecurityController` - вход и выход.
- `DefaultController` - основные страницы кабинета и админские действия.
- `PublicAPIController` - публичные API-методы под `/api`.
- `InternalAPIController` - внутренние API-методы.
- `SpecialistController`, `DepartmentController`, `PageController`, `BannerController` - справочники и CMS-часть.
- `WidgetController`, `WidgetFormController` - формы и виджеты.
## Ассеты
Команды из `package.json`:
```bash
yarn dev
yarn watch
yarn build
```
В контейнере:
```bash
docker exec -it php82 yarn install
docker exec -it php82 yarn dev
```
## Backend-команды
Установка зависимостей:
```bash
docker exec -it php82 composer install
```
Миграции:
```bash
docker exec -it php82 php bin/console doctrine:migrations:migrate
```
Список app-команд:
```bash
docker exec -it php82 php bin/console list app
```
## Важное замечание
Внутри `apps/cabinet` есть отдельный старый `docker-compose.yml` под PHP 7.4 и локальный nginx. Основной репозиторий сейчас использует общий compose из `environments` и контейнер `php82`, поэтому старый compose стоит рассматривать как исторический или альтернативный сценарий.
+451
View File
@@ -0,0 +1,451 @@
---
title: Расписание врачей — синхронизация и отображение (Backend + Cabinet)
---
# Расписание врачей: синхронизация и отображение
> Полный разбор того, **как в проекте работает расписание врачей**: от Infoclinica MIS до карточки на сайте. Охватывает `apps/backend`, `apps/cabinet` и роль adminPanel.
>
> Связанные документы:
> - [2.2 Расписание и кеш (Backend)](../apps/backend-scenarios/schedule-cache.md)
> - [2.3 GetScheduleMessage](../apps/backend-scenarios/schedule-messenger.md)
> - [4.1 Синхронизация врачей (Idoctor)](../apps/backend-scenarios/sync-doctors-reviews.md)
> - [Backend: внешние сервисы](../infrastructure/backend-external-services.md) (MIS / Widget API)
> - [Cabinet: обзор](./cabinet.md)
---
## 0. TL;DR
В проекте **нет единого batch-sync всех слотов** всех врачей. Вместо этого работают **два независимых механизма**:
| Что | Как обновляется | Где хранится |
| --- | --- | --- |
| **Слоты времени** (`09:0009:30` и т.д.) | **По запросу** из Infoclinica MIS | Backend: таблица `schedule` (кэш 5 мин). Cabinet: **не хранит**, тянет live через `/api/interval` |
| **Ближайшая дата приёма** (`nearestDate`) | **Пакетно, раз в час** (cron) | Backend: `idoctor``location` / `specialist` через SQL-функции; Cabinet читает `location_view` |
**Cabinet и backend — параллельные потребители Infoclinica.** Cabinet **не вызывает** backend `GET /specialist/schedule`.
Источник правды по слотам — **Infoclinica MIS** (`MIS_URL` / `MIS` env, обычно `widget.sovamed.ru`).
---
## 1. Общая архитектура
```mermaid
flowchart TB
subgraph MIS [Infoclinica MIS]
API1["GET /api/reservation/intervals"]
API2["GET /api/reservation/schedule"]
API3["GET /specialists/doctors"]
API4["POST /api/reservation/anonymous-reserve"]
end
subgraph Backend [apps/backend]
EP1["GET /specialist/schedule"]
Handler["GetScheduleMessageHandler"]
Cache["ScheduleCacheService → schedule"]
Cron["upload:doctors 0/1"]
Idoctor[(idoctor)]
Location[(location)]
SP["update_specialist() / update_location()"]
end
subgraph Cabinet [apps/cabinet]
EP2["GET /api/interval"]
Stimulus["checkSchedule_controller.js"]
Views[(specialist_view / location_view)]
end
subgraph Admin [adminPanel + backend API]
AdminAPI["GET /idoctor/list\nCRUD specialist/location"]
end
EP1 --> Handler --> Cache
Handler --> API1
Cache --> API1
Cron --> API3 --> Idoctor --> SP --> Location
Stimulus --> EP2
EP2 --> API1
EP2 --> API2
AdminAPI --> Idoctor
Views -.->|"nearestDate для сортировки"| Location
```
---
## 2. Два типа «расписания»
### 2.1. Слоты (интервалы времени)
**Бизнес-смысл:** пациент видит конкретные свободные окна для записи.
**Поведение:** данные **не синхронизируются заранее**. При открытии карточки врача frontend запрашивает MIS (напрямую или через прокси backend/cabinet). Ответ содержит:
- `workDate` — дата приёма;
- `isFree` — есть ли свободные слоты в этот день;
- `intervals[]` — массив `{ time, schedident, isFree, rnum? }`.
**Кэш:** короткий (5 мин в backend, HTTP-кэш в cabinet), только чтобы снизить нагрузку на MIS.
### 2.2. Ближайшая дата (`nearestDate`)
**Бизнес-смысл:** «ближайшая запись — 28 мая», сортировка врачей «по времени приёма», фильтр по диапазону дат.
**Поведение:** **реальная синхронизация** — hourly cron тянет список врачей из MIS, сохраняет `nearestDate` в `idoctor`, затем SQL-функции обновляют `location` и view'ы cabinet.
**Не используется** для построения сетки слотов — слоты всегда live из MIS.
---
## 3. Backend (`apps/backend`)
### 3.1. API слотов: `GET /specialist/schedule`
**Точка входа:** `SpecialistController::specialistSchedule`
**Параметры** (`ScheduleDto`):
| Параметр | Тип | Описание |
| --- | --- | --- |
| `st`, `en` | integer | Границы периода в формате `Ymd` (например `20250525`) |
| `dcode` | integer | Код врача в Infoclinica |
| `filial` | integer | ID филиала (в query к MIS уходит как `filialId`) |
| `onlineMode` | boolean | `0` — очный приём, `1` — онлайн |
Query string для MIS и ключа кэша:
```php
// apps/backend/src/Dto/ScheduleDto.php
return http_build_query([
'st' => $this->st,
'en' => $this->en,
'dcode' => $this->dcode,
'onlineMode' => $this->onlineMode,
'filialId' => $this->filial,
]);
```
### 3.2. Цепочка вызовов
```
SpecialistController::specialistSchedule()
→ SpecialistService::getSchedule(ScheduleDto)
→ MessageBus dispatch GetScheduleMessage (sync transport)
→ GetScheduleMessageHandler::__invoke()
→ ScheduleCacheService::getCachedSchedule() // TTL 5 мин
→ InfoclinicaClientService::getSchedule() // при промахе
→ ScheduleCacheService::saveSchedule()
```
Подробнее: [schedule-messenger.md](../apps/backend-scenarios/schedule-messenger.md), [schedule-cache.md](../apps/backend-scenarios/schedule-cache.md).
### 3.3. Запрос в MIS
`InfoclinicaClientService` вызывает:
```
GET {MIS_URL}/api/reservation/intervals?st=...&en=...&dcode=...&onlineMode=...&filialId=...
```
Ответ нормализуется в структуру:
```json
{
"schedule": {
"<depnum>": {
"<Ymd>": {
"schedident": "...",
"dcode": "...",
"filial": 1,
"isFree": true,
"intervals": [
{ "time": "09:00-09:30", "isFree": true }
]
}
}
},
"nearestDate": { "<depnum>": 20250525 }
}
```
### 3.4. Таблица `schedule` — кэш слотов
Одна строка = **один интервал** (не «расписание врача целиком»).
| Поле | Назначение |
| --- | --- |
| `dcode`, `department`, `filial` | Привязка к врачу / отделению / филиалу |
| `schedident` | ID расписания в MIS (нужен для записи) |
| `workdate` | Дата приёма |
| `time` | Интервал `"HH:MM-HH:MM"` |
| `intervalIsFree` | Свободен ли слот |
| `onlineMode` | Очный / онлайн |
| `queryString` | Ключ кэша (полный query string запроса) |
| `createdAt` | Время записи в кэш |
| `priceInfo` | JSON, только для онлайн-расписания |
**TTL:** 5 минут. При `saveSchedule` старые строки с тем же `queryString` + `onlineMode` удаляются перед insert.
**Очистка:** команда `app:schedule:clear-cache` (по умолчанию старше 24 ч). В cron **не подключена**.
### 3.5. Batch-sync `nearestDate`: `upload:doctors`
**Команда:** `php bin/console upload:doctors [onlineMode]`
- `onlineMode=0` — очные врачи;
- `onlineMode=1` — онлайн.
**Алгоритм:**
1. Читает активные отделения из `department`.
2. Для каждого отделения вызывает MIS:
```
GET /specialists/doctors?departments={did}&onlineMode={0|1}&firstrow=&lastrow=
```
чанками по 300 записей.
3. Upsert в `idoctor` по ключу `dcode + department + onlineMode`.
4. Поля: `dcode`, `name`, `department`, `filial`, **`nearestDate`**, `onlineMode`.
Подробнее: [sync-doctors-reviews.md](../apps/backend-scenarios/sync-doctors-reviews.md).
### 3.6. Hourly cron
Файл `scripts/cron.hourly.sh`:
```bash
docker exec -t -u www-data php84 php bin/console upload:doctors 0
docker exec -t -u www-data php84 php bin/console upload:doctors 1
docker exec -t pgsql psql -U sova_api -d sova_api -c "SELECT public.update_specialist();"
docker exec -t pgsql psql -U sova_api -d sova_api -c "SELECT public.update_location();"
```
```mermaid
sequenceDiagram
participant Cron as cron.hourly.sh
participant Cmd as upload:doctors
participant MIS as Infoclinica MIS
participant DB as PostgreSQL
Cron->>Cmd: upload:doctors 0 (offline)
Cron->>Cmd: upload:doctors 1 (online)
loop каждое активное отделение
Cmd->>MIS: GET /specialists/doctors
MIS-->>Cmd: dcode, nearestDate, ...
Cmd->>DB: upsert idoctor
end
Cron->>DB: SELECT update_specialist()
Cron->>DB: SELECT update_location()
```
**Важно:** функции `update_specialist()` и `update_location()` вызываются из cron, но **их исходников нет в репозитории** — они живут в production PostgreSQL. Без них `nearestDate` в cabinet не обновится, хотя `idoctor` будет актуален.
### 3.7. Роль adminPanel
Admin panel **не синхронизирует слоты**. Она настраивает **метаданные**:
| Поле | Сущность | Назначение |
| --- | --- | --- |
| `displaySchedule` | `specialist` | Показывать ли виджет расписания |
| `scheduleText` | `specialist` | Текст под расписанием |
| `dcodes` | `specialist` | Коды врача в Infoclinica |
| `dcode`, `department`, `filial`, `onlineMode` | `location` | Привязка врача к MIS |
| `nearestDate` | `location` | Можно задать вручную или взять из `idoctor` |
Backend API:
- `GET /idoctor/list` — список синхронизированных врачей из Infoclinica (для модалки «Добавить расписание из Инфоклиники»);
- `POST/PUT /specialist/{id}/location/*` — CRUD локаций.
Без правильной `location` frontend не знает, какой `dcode` / филиал / отделение запрашивать у MIS.
### 3.8. Запись на приём (backend)
После выбора слота:
```
POST /reservation/anonymous-reserve
→ SpecialistService::createAnonymousReserve()
→ GetAnonymousReserveRequestMessageHandler
→ InfoclinicaClientService::anonymousReserve()
→ POST {MIS_URL}/api/reservation/anonymous-reserve
```
Поля из слота: `schedident`, `workDate`, `time`, `dcode`, `filial`, `rnum`.
Подробнее: [anonymous-reserve.md](../apps/backend-scenarios/anonymous-reserve.md).
---
## 4. Cabinet (`apps/cabinet`)
Cabinet — Symfony + Twig + Stimulus. **Не SPA.** Расписание на публичном сайте (`cabinet.sovamed.ru` / список врачей) реализовано здесь.
### 4.1. Принцип: live-запросы, не backend API
Cabinet **не вызывает** `apps/backend` `/specialist/schedule`. Свой прокси:
```
GET /api/interval?doctor=...&department=...&filial=...&startInterval=2025-05-25&endInterval=2025-06-01&onlineMode=0&update=true
```
**Контроллер:** `PublicAPIController::interval()`
Два параллельных запроса в MIS:
| MIS endpoint | Назначение |
| --- | --- |
| `GET /api/reservation/schedule` | Сетка рабочих дней (когда врач принимает) |
| `GET /api/reservation/intervals` | Конкретные слоты внутри дней |
Результат мержится: для каждого свободного дня из `schedule` подтягиваются `intervals` с тем же `schedident`.
HTTP-кэш: `CachingHttpClient` + store в `var/HttpClient` (короткий, не PostgreSQL).
### 4.2. Диапазон дат на странице врачей
`SpecialistController::index()`:
- если в фильтре задан `specialist_search[current_date]` — используется он;
- иначе **сегодня → +7 дней**.
Даты передаются в DOM как `data-st` / `data-en` на `.specialist-items` и используются Stimulus-контроллером.
### 4.3. Клиент: `checkSchedule_controller.js`
При загрузке карточки врача (если `specialist.infoclinica == true`):
1. Читает `dcode`, `filial`, `department`, `onlineMode` из DOM / селекта клиники.
2. Вызывает `GET /api/interval`.
3. Рендерит до **6 свободных слотов** на карточке.
4. Кнопка «Все даты» открывает модалку (`specialistView_controller.js`) с навигацией по неделям — тот же `/api/interval`.
**Формат значения селекта клиники:** `dcode:filial:department:onlineMode:infoclinica`
**Twig:** `templates/specialist/_item.html.twig` — монтирует `data-controller="checkSchedule"`.
### 4.4. Врачи без Infoclinica
Если `specialist.infoclinica == false` (Bitrix `HIDE_TIMETABLE` или нет привязки к MIS):
- контроллер `checkSchedule` **не монтируется**;
- вместо слотов — кнопка «Записаться» → заявка в Bitrix CRM (`uslugi_controller.js`).
### 4.5. `nearestDate` в cabinet
Cabinet читает `nearestDate` из read-only view `location_view` (`LocationView` entity):
- сортировка «по времени приёма»;
- фильтр по диапазону дат в поиске;
- **не** для построения сетки слотов.
View обновляется SQL-функциями backend cron (`update_location()`).
### 4.6. Запись на приём (cabinet)
1. Клик по слоту → `record.js` → форма записи.
2. **Авторизованный пользователь:** `webSDK.scheduleRecReserve()` напрямую в Infoclinica SDK (`widget.sovamed.ru`).
3. **Анонимный:** `POST /api/anonymous-reserve` → MIS `/api/reservation/anonymous-reserve`.
### 4.7. Legacy: старые CLI-команды cabinet
В `apps/cabinet/src/Command/` есть **устаревший** путь полной синхронизации в локальные таблицы:
```bash
php bin/console app:Infoclinica schedules # → таблица Schedule
php bin/console app:Infoclinica intervals # → таблица Interval
php bin/console upload:doctorsInfoclinica # → таблица Idoctor
```
**Статус: legacy.** Миграция `Version20250907100913` удалила таблицы `specialist`, `location`, `idoctor` из cabinet. Runtime UI перешёл на DB views (`specialist_view`, `location_view`), batch-sync — в backend (`upload:doctors`).
Текущий cabinet **не читает** локальные `Schedule` / `Interval` для UI.
---
## 5. Сравнение Backend vs Cabinet
| Аспект | Backend | Cabinet |
| --- | --- | --- |
| Endpoint слотов | `GET /specialist/schedule` | `GET /api/interval` |
| MIS endpoints | только `/intervals` | `/schedule` + `/intervals` |
| Кэш слотов | PostgreSQL `schedule`, 5 мин | HTTP cache `var/HttpClient` |
| Batch sync `nearestDate` | `upload:doctors` + SQL functions | Читает `location_view` |
| Frontend | API для внешних клиентов | Stimulus JS на Twig-страницах |
| Запись | `/reservation/anonymous-reserve` | `/api/anonymous-reserve` + WrSDK |
---
## 6. Ключевые файлы
### Backend
| Файл | Роль |
| --- | --- |
| `src/Controller/SpecialistController.php` | `GET /specialist/schedule` |
| `src/Service/Specialist/SpecialistService.php` | dispatch messenger |
| `src/MessageHandler/GetScheduleMessageHandler.php` | cache + MIS |
| `src/Service/ScheduleCache/ScheduleCacheService.php` | TTL 5 мин |
| `src/Service/Client/InfoclinicaClientService.php` | HTTP к MIS |
| `src/Command/UploadDoctorsCommand.php` | hourly sync `nearestDate` |
| `src/Entity/Schedule.php`, `Idoctor.php`, `Location.php` | модели |
| `scripts/cron.hourly.sh` | orchestration |
### Cabinet
| Файл | Роль |
| --- | --- |
| `src/Controller/PublicAPIController.php` | `GET /api/interval` |
| `assets/controllers/checkSchedule_controller.js` | слоты на карточке |
| `assets/controllers/specialistView_controller.js` | модалка «Все даты» |
| `templates/specialist/_item.html.twig` | разметка карточки |
| `src/Controller/SpecialistController.php` | диапазон дат st/en |
| `src/Entity/LocationView.php` | `nearestDate` для фильтров |
---
## 7. Отладка
```bash
# Batch sync nearestDate
docker exec -u www-data php84 php bin/console upload:doctors 0 -v
docker exec pgsql psql -U sova_api -d sova_api -c "SELECT dcode, nearest_date, online_mode FROM idoctor LIMIT 10;"
# Статистика кэша слотов backend
docker exec -u www-data php84 php bin/console app:schedule:clear-cache --stats
# Прямой запрос backend API
curl "http://localhost:8081/specialist/schedule?st=20250525&en=20250601&dcode=XXX&filial=1&onlineMode=0"
# Cabinet proxy
curl "http://localhost:8082/api/interval?doctor=XXX&department=YYY&filial=1&startInterval=2025-05-25&endInterval=2025-06-01&onlineMode=0"
```
---
## 8. Известные нюансы
1. **`GetScheduleMessageHandler`** вызывает `getSchedule($queryString, $isOnlineMode)` с двумя аргументами, а `InfoclinicaClientService::getSchedule()` в репозитории принимает один — потенциальная runtime-ошибка; проверить на deploy-окружении.
2. **`update_specialist()` / `update_location()`** критичны для hourly sync, но **не в git** — только в production PostgreSQL.
3. **`app:schedule:clear-cache`** не в cron — старые строки в `schedule` копятся, но не отдаются (TTL проверяется при чтении).
4. **Cabinet и backend не связаны по слотам** — изменения в backend cache не влияют на cabinet.
5. **Legacy-команды cabinet** (`app:Infoclinica schedules/intervals`) ссылаются на удалённые таблицы — не использовать для текущего UI.
---
## 9. Ментальная модель
| Задача | Механизм | Хранение | Триггер |
| --- | --- | --- | --- |
| Показать слоты на сайте | Live-запрос в MIS | Не хранится (HTTP cache) | Открытие карточки врача |
| API слотов для внешних клиентов | Live + DB cache 5 мин | `schedule` | `GET /specialist/schedule` |
| Сортировка «по ближайшей записи» | Batch sync | `idoctor` → `location` → views | Hourly cron |
| Привязка врача к MIS | Admin + sync | `location`, `specialist.dcodes` | Admin panel + cron |
| Запись на приём | Push в MIS | `record` (локально) | POST anonymous-reserve |
+216
View File
@@ -0,0 +1,216 @@
---
title: Онлайн-консультация в личном кабинете
---
# Онлайн-консультация (cabinet)
> Как устроена запись на **онлайн-приём** в `apps/cabinet`: маршруты, MIS/Widget API, оплата и видеоконференция. Связано с [расписанием врачей](./doctor-schedule-sync.md).
---
## TL;DR
| Этап | Где | Внешний ресурс |
|------|-----|----------------|
| Список онлайн-врачей | `GET /online-specialists` (нужен login) | PostgreSQL `location_view` (`online_mode=true`) |
| Слоты | `GET /api/interval?onlineMode=1` | `MIS``widget.sovamed.ru` |
| Запись | `webSDK.scheduleRecReserve({ onlineType: 1 })` | Infoclinica SDK в браузере |
| Оплата / видео | `/case-history` | `webSDK.loadPaymentView`, `openConference` |
Cron `upload:doctors 1` (backend) обновляет **ближайшие даты** для онлайн-локаций, не сами слоты.
---
## 1. Маршруты
| URL | Auth | Назначение |
|-----|------|------------|
| `/online-specialists` | `ROLE_USER` | Список врачей с онлайн-локациями |
| `/specialists` | публичный | Очный приём (`onlineMode=0`) |
| `/api/interval` | публичный | Прокси расписания в MIS |
| `/case-history` | `ROLE_USER` | Записи, оплата, «Онлайн приём», возврат |
| `/refund` | `ROLE_USER` | Форма возврата |
Код: [`SpecialistController.php`](../../apps/cabinet/src/Controller/SpecialistController.php), [`PublicAPIController.php`](../../apps/cabinet/src/Controller/PublicAPIController.php), [`SecurityController.php`](../../apps/cabinet/src/Controller/SecurityController.php).
---
## 2. Поток данных
```mermaid
sequenceDiagram
participant User as Пользователь ЛК
participant Cab as cabinet
participant MIS as widget.sovamed.ru
participant SDK as webSDK браузер
User->>Cab: GET /online-specialists
Cab->>Cab: location_view online_mode=1
User->>Cab: GET /api/interval onlineMode=1
Cab->>MIS: /api/reservation/schedule + /intervals
MIS-->>Cab: intervalsData
User->>SDK: scheduleRecReserve onlineType=1
User->>Cab: GET /case-history
Cab->>SDK: records, payment, conference
```
---
## 3. Отличия online vs offline
| | Offline | Online |
|---|---------|--------|
| Список | `/specialists` | `/online-specialists` + login |
| `onlineMode` в API | `0` | `1` |
| Анонимная запись | да (`/api/anonymous-reserve`) | **нет** |
| Согласия | PD | PD + оферта + ИДС |
| После записи | `#doctor-success` | `#online` + предупреждение об оплате 5 мин |
| Оплата / видео | — | `/case-history` |
---
## 4. Типичные причины поломки
### 4.1. Пустой список `/online-specialists`
- Нет строк в `location_view` с `online_mode = true` (cron `upload:doctors 1` + `update_location()`).
- Врач не привязан к MIS (`dcode`, `department`, `filial`).
**Проверка:** SQL `SELECT * FROM location_view WHERE online_mode = true;`
### 4.2. Нет слотов на карточке
- MIS недоступен (`MIS` env, `widget.sovamed.ru`).
- Неверный `filial`/`department` для онлайн-режима.
- `GET /api/interval?onlineMode=1` возвращает 5xx.
**Проверка:** `curl "https://widget.sovamed.ru/specialists/departments"` и `/api/interval` с prod.
### 4.3. Запись создаётся как очная
- Баг нормализации `onlineMode` в JS/PHP (исправлено: `OnlineMode`, `onlineMode.js`).
- В `scheduleRecReserve` уходит `onlineType: 0` вместо `1`.
### 4.4. Фильтр на онлайн-странице уводит на `/specialists`
- Форма поиска post'ила на `specialist_index` (исправлено → `specialist_online_index`).
### 4.5. Оплата / видео не работает
- Не загружен SDK (`loader.js``widget.sovamed.ru/.../sdk.build.min.js`).
- Запись не оплачена в течение 5 мин (MIS отменяет).
- `webSDK.openConference` — вне окна времени приёма.
### 4.6. Авторизация внутри iframe (`#iframeProtocol`) — не должна появляться
**Симптом:** пользователь уже в ЛК, но в модальном окне «Онлайн приём» или «Оплата» внутри iframe просят войти снова.
**Причина:** две независимые сессии:
| Сессия | Где хранится | За что отвечает |
|--------|--------------|-----------------|
| Symfony (`ROLE_USER`) | cookie `cabinet.sovamed.ru` | `/case-history`, `/online-specialists` |
| MIS / webSDK | cookie `widget.sovamed.ru` + скрытый iframe `/sdk` | `openConference`, `loadPaymentView`, `scheduleRecReserve` |
Symfony может быть активна, а MIS — нет (истёк timeout, другой браузер, блокировка third-party cookies).
**Где iframe:**
| Действие | Метод SDK | Элемент |
|----------|-----------|---------|
| «Онлайн приём» | `openConference()` | `#iframeProtocol` — URL видеоконференции |
| Вход через Госуслуги | `loadLoginView()` | `#iframeProtocol`**ожидаемо** показывает login |
| Оплата | `loadPaymentView()` | виджет ЮKassa в `#popup-body` (не login-iframe) |
**Исправление в коде (cabinet):**
- `assets/components/misSession.js` — проверка `webSDK.data.user.authenticated` / `isLoggedIn()` перед оплатой и конференцией.
- `caseHistory_controller.js``openConference` **без** `container` (DOM-элемент ломал SDK `eval(container)` и пропускал Guest URL); iframe переносится в popup через `mountConferenceInPopup()`.
- При протухшей MIS-сессии — popup «Войти снова» → `/logout`, а не форма login внутри iframe.
**Проверка в браузере (prod):**
```javascript
// на /case-history, до клика «Онлайн приём»
window.webSDK?.data?.user?.authenticated // должно быть true
```
```bash
# Symfony-сессия (smoke-скрипт)
curl -b cookies.txt https://cabinet.sovamed.ru/api/userInfo
# → {"data":"<uid>"} — это НЕ гарантия MIS-сессии
```
**Smoke:**
```bash
make local-online-smoke
# шаги 9–10: UI-маркеры case-history, /api/userInfo, доступность MIS SDK
```
### 4.7. MIS / Infoclinica
Env: `MIS=https://widget.sovamed.ru` в [`apps/cabinet/.env`](../../apps/cabinet/.env).
Публичный DNS: CNAME `production.infoclinica.ru``217.74.42.159` (не путать с внутренними IP вроде `10.34.23.239`).
---
## 5. Тесты
### Shell smoke (Docker local)
```bash
make local-up
make local-online-smoke
```
Скрипт: [`scripts/online-consultation-smoke.sh`](../../scripts/online-consultation-smoke.sh)
### Pytest e2e
```bash
./scripts/run-online-consultation-pytest.sh
```
Каталог: [`tests/e2e/online_consultation/`](../../tests/e2e/online_consultation/)
### PHPUnit (cabinet)
```bash
make cabinet-test
# или
docker exec php82-local php bin/phpunit tests/Unit tests/Controller
```
- `tests/Unit/Support/OnlineModeTest.php` — нормализация `0/1/true/false`
- `tests/Controller/OnlineSpecialistsControllerTest.php` — auth на `/online-specialists`
---
## 6. Чеклист диагностики на prod
1. Пользователь залогинен? `/online-specialists` без login → redirect.
2. Есть онлайн-локации в БД? `location_view.online_mode = true`.
3. `docker exec php84 php bin/console upload:doctors 1` — без ошибок?
4. `/api/interval?...&onlineMode=1` — HTTP 200 и `intervalsData`?
5. В браузере загружен `webSDK`? Console → нет 404 на `sdk.build.min.js`.
6. `/case-history` — запись с `onlineType`, статус оплаты?
7. **До «Онлайн приём»:** `window.webSDK.data.user.authenticated === true`? Если `false` — MIS-сессия протухла (см. §4.6).
8. Iframe `#iframeProtocol`: `src` — URL конференции, а не `/login`?
---
## 7. Ключевые файлы
| Файл | Роль |
|------|------|
| `src/Controller/SpecialistController.php` | `/online-specialists` |
| `src/Controller/PublicAPIController.php` | `/api/interval` |
| `src/Repository/SpecialistViewRepository.php` | фильтр `onlineMode` |
| `src/Support/OnlineMode.php` | нормализация флага |
| `assets/components/record.js` | запись, `onlineType` |
| `assets/components/misSession.js` | проверка MIS-сессии, mount iframe конференции |
| `assets/controllers/caseHistory_controller.js` | оплата, конференция |
| `assets/components/loader.js` | загрузка SDK |
| `templates/specialist/_item.html.twig` | карточка врача |