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
Vendored
BIN
View File
Binary file not shown.
+8 -4
View File
@@ -17,11 +17,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate site files
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Build VitePress site
run: |
test -f site/index.html
test -f site/README.md
test -f site/docs/test-contour-article.md
npm ci
npm run build
test -f .vitepress/dist/index.html
test -f infrastructure/test-contour/test-contour-article.md
parse-tag:
if: startsWith(github.ref, 'refs/tags/docs-v')
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.vitepress/cache/
.vitepress/dist/
+113
View File
@@ -0,0 +1,113 @@
import { defineConfig } from 'vitepress'
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
export default defineConfig({
lang: 'ru-RU',
title: 'Sova: документация проекта',
description: 'Онбординг, запуск и эксплуатация инфраструктуры Sova',
cleanUrls: true,
ignoreDeadLinks: true,
markdown: {
config(md) {
const defaultFence = md.renderer.rules.fence
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
const info = token.info.trim().split(/\s+/)[0]
if (info === 'mermaid') {
return `<pre class="mermaid">${escapeHtml(token.content)}</pre>`
}
return defaultFence
? defaultFence(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
}
},
themeConfig: {
nav: [
{ text: 'Старт', link: '/quick-start' },
{ text: 'Архитектура', link: '/architecture' },
{ text: 'Test-контур k3s', link: '/infrastructure/test-contour/' },
{ text: 'Инфраструктура', link: '/infrastructure/docker' }
],
sidebar: [
{
text: 'Онбординг',
items: [
{ text: 'Обзор', link: '/' },
{ text: 'Быстрый старт', link: '/quick-start' },
{ text: 'Проверка локального контура', link: '/testing' },
{ text: 'Окружение', link: '/environment' },
{ text: 'Архитектура', link: '/architecture' },
{ text: 'Потоки данных', link: '/flows' },
{ text: 'API и Swagger', link: '/api-routes' },
{ text: 'Модели данных', link: '/data-model' },
{ text: 'Документация VitePress', link: '/docs-site' }
]
},
{
text: 'Приложения',
items: [
{ text: 'Backend API', link: '/apps/backend' },
{ text: 'Backend: DDD / бизнес-сущности', link: '/apps/backend-ddd' },
{
text: 'Backend: бизнес-сценарии (use cases)',
collapsed: true,
items: [
{ text: 'Оглавление', link: '/apps/backend-scenarios/' },
{ text: '1.1 Логин и JWT', link: '/apps/backend-scenarios/login-jwt' },
{ text: '1.2 UID / pcode', link: '/apps/backend-scenarios/auth-uid-pcode' },
{ text: '1.3 Смена региона', link: '/apps/backend-scenarios/change-region' },
{ text: '2.1 Карточка врача и локации', link: '/apps/backend-scenarios/specialist-card-locations' },
{ text: '2.2 Расписание и кеш', link: '/apps/backend-scenarios/schedule-cache' },
{ text: '2.3 GetScheduleMessage', link: '/apps/backend-scenarios/schedule-messenger' },
{ text: '2.4 Расписание: полный мануал (Backend + Cabinet)', link: '/apps/doctor-schedule-sync' },
{ text: '3.1 Анонимная запись', link: '/apps/backend-scenarios/anonymous-reserve' },
{ text: '3.2 SMS и Record', link: '/apps/backend-scenarios/sms-record' },
{ text: '3.3 Киоск checkpass', link: '/apps/backend-scenarios/kiosk-checkpass' },
{ text: '4.1 Синхронизация врачей/отзывов', link: '/apps/backend-scenarios/sync-doctors-reviews' },
{ text: '4.2 Calltouch', link: '/apps/backend-scenarios/calltouch-lead' },
{ text: '4.3 XML-фид Яндекса', link: '/apps/backend-scenarios/xml-yandex-feed' }
]
},
{ text: 'Backend: CRUD контента', link: '/apps/backend-content-crud' },
{ text: 'adminPanel: обзор', link: '/apps/admin-panel' },
{ text: 'adminPanel: CRUD контента', link: '/apps/admin-panel-content-crud' },
{ text: 'Backend: архитектура модулей', link: '/apps/backend-architecture' },
{ text: 'Cabinet', link: '/apps/cabinet' },
{ text: 'Cabinet: онлайн-консультация', link: '/apps/online-consultation' },
{ text: 'Cabinet: архитектура модулей', link: '/apps/cabinet-architecture' }
]
},
{
text: 'Инфраструктура',
items: [
{ text: 'Docker Compose', link: '/infrastructure/docker' },
{ text: 'K8s + Terraform + ArgoCD + Gitea', link: '/infrastructure/k8s-cicd-platform-plan' },
{ text: 'Backend: внешние сервисы (test/stage/prod)', link: '/infrastructure/backend-external-services' },
{
text: 'Test-контур k3s (песочница)',
collapsed: false,
items: [
{ text: 'Обзор', link: '/infrastructure/test-contour/' },
{ text: 'Что сделано + перенос на сервер', link: '/infrastructure/test-contour/test-contour-article' },
{ text: 'Система тегов CI/CD', link: '/infrastructure/test-contour/tags' },
{ text: 'ArgoCD: sova-root и data-test', link: '/infrastructure/test-contour/argocd-apps' }
]
},
{ text: 'Эксплуатация', link: '/operations/maintenance' }
]
}
],
socialLinks: []
}
})
+44
View File
@@ -0,0 +1,44 @@
import DefaultTheme from 'vitepress/theme'
import mermaid from 'mermaid'
import { nextTick, watch } from 'vue'
import { useRoute } from 'vitepress'
import './style.css'
const renderMermaid = async () => {
await nextTick()
const diagrams = Array.from(
document.querySelectorAll<HTMLElement>('.mermaid')
)
diagrams.forEach((diagram) => {
diagram.removeAttribute('data-processed')
})
await mermaid.run({ nodes: diagrams })
}
export default {
extends: DefaultTheme,
setup() {
if (typeof window === 'undefined') {
return
}
const route = useRoute()
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: 'default'
})
watch(
() => route.path,
() => {
renderMermaid()
},
{ immediate: true }
)
}
}
+15
View File
@@ -0,0 +1,15 @@
.mermaid {
margin: 24px 0;
padding: 16px;
overflow-x: auto;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
text-align: center;
white-space: pre;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
+10 -1
View File
@@ -1,4 +1,13 @@
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /docs
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY site/ /usr/share/nginx/html/
COPY --from=builder /docs/.vitepress/dist /usr/share/nginx/html
EXPOSE 8080
+209
View File
@@ -0,0 +1,209 @@
# API, HTTP-методы и Swagger
## Swagger / OpenAPI
Backend API:
- UI: `http://localhost:8081/docs`
- JSON: `http://localhost:8081/api/doc.json`
- Конфиг: `apps/backend/config/routes/nelmio_api_doc.yaml`
- В документацию backend сейчас попадают только path patterns из `apps/backend/config/packages/nelmio_api_doc.yaml`.
Cabinet:
- UI: `http://localhost:8082/api/swagger`
- JSON: `http://localhost:8082/api/swagger.json`
- Реализовано вручную в `apps/cabinet/src/Controller/InternalAPIController.php`.
## Backend API
### Пользователь
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `GET` | `/user/logout` | `UserController::logout` | Logout-заглушка для клиента |
| `POST` | `/user/login` | `UserController::login` | Логин по email/password, выдача JWT |
| `GET` | `/user/` | `UserController::index` | Текущий пользователь по JWT |
| `PUT` | `/user/change-region` | `UserController::changeRegion` | Смена региона пользователя |
| `POST` | `/user/auth` | `UserController::auth` | Регистрация/авторизация |
| `POST` | `/user/auth-by-pcode` | `UserController::authByPcode` | Авторизация по pcode и дате рождения |
### Справочники и контент
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `GET` | `/filial/list` | `FilialController` | Список филиалов |
| `GET` | `/filial/by-region/{regionId}` | `FilialController` | Филиалы по региону |
| `PUT` | `/filial/{fid}` | `FilialController` | Обновление филиала |
| `POST` | `/filial/create` | `FilialController` | Создание филиала |
| `GET` | `/filial/picture/{id}` | `FilialController` | Картинка филиала |
| `POST` | `/filial/picture/{id}` | `FilialController` | Загрузка картинки филиала |
| `GET` | `/department/list` | `DepartmentController` | Список отделений |
| `PUT` | `/department/{did}` | `DepartmentController` | Обновление отделения |
| `POST` | `/department/create` | `DepartmentController` | Создание отделения |
| `GET` | `/pricelist/list` | `PriceListController` | Прайс |
| `GET` | `/pricelist/department` | `PriceDepartmentController` | Группы прайса |
### Врачи и расписание
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `POST` | `/specialist/create` | `SpecialistController` | Создание врача |
| `PUT` | `/specialist/{id}` | `SpecialistController` | Обновление врача |
| `DELETE` | `/specialist/{id}` | `SpecialistController` | Удаление врача |
| `GET` | `/specialist/list` | `SpecialistController` | Список врачей |
| `GET` | `/specialist/post` | `SpecialistController` | Список должностей |
| `GET` | `/specialist/picture/{id}` | `SpecialistController` | Фото врача |
| `POST` | `/specialist/picture/{id}` | `SpecialistController` | Загрузка фото врача |
| `GET` | `/specialist/schedule` | `SpecialistController` | Расписание врача |
| `GET` | `/specialist/{id}` | `SpecialistController` | Детальная карточка по id |
| `GET` | `/specialist/by/{identifier}` | `SpecialistController` | Детальная карточка по alias/id |
| `POST` | `/specialist/{id}/location/create` | `LocationController` | Создание локации врача |
| `PUT` | `/specialist/{specialistId}/location/{id}` | `LocationController` | Обновление локации врача |
| `GET` | `/specialist-dcode-description/list` | `SpecialistDcodeDescriptionController` | Описания dcode |
| `GET` | `/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Описание dcode |
| `POST` | `/specialist/{specialistId}/specialist-dcode-description/create` | `SpecialistDcodeDescriptionController` | Создание описания dcode |
| `PUT` | `/specialist/{specialistId}/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Обновление описания dcode |
| `DELETE` | `/specialist-dcode-description/{id}` | `SpecialistDcodeDescriptionController` | Удаление описания dcode |
| `GET` | `/specialist-docs/list` | `SpecialistDocsController` | Документы врачей |
| `GET` | `/specialist-docs/{id}` | `SpecialistDocsController` | Документ врача |
| `GET` | `/specialist-docs/picture/{id}` | `SpecialistDocsController` | Картинка документа |
| `POST` | `/specialist/{id}/specialist-docs/create` | `SpecialistDocsController` | Создание документа |
| `PUT` | `/specialist/{specialistId}/specialist-docs/{id}` | `SpecialistDocsController` | Обновление документа |
| `DELETE` | `/specialist-docs/{id}` | `SpecialistDocsController` | Удаление документа |
| `POST` | `/specialist-docs/picture/{id}` | `SpecialistDocsController` | Загрузка картинки документа |
| `DELETE/PUT` | `/stock/{id}/specialist/{specialistId}` | `StockSpecialistController` | Связь акции и врача |
### CRUD-контент
Эти контроллеры имеют одинаковый набор методов:
| Контроллер | Базовый путь | Методы |
| --- | --- | --- |
| `ArticleController` | `/article` | `GET /list`, `GET /alias/{alias}`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `DiseaseController` | `/disease` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `MedicalCenterController` | `/medical-center` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `NewsController` | `/news` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `PromoController` | `/promo` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `ReviewController` | `/review` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
| `SiteServiceController` | `/site-services` | `GET /list`, `GET /{id}`, `POST /create`, `PUT /{id}`, `DELETE /{id}` |
Для контентных сущностей `POST`, `PUT` и `DELETE` доступны только с JWT пользователя с ролью `ROLE_ADMIN`.
Списки `news`, `promo`, `medical-center`, `disease`, `site-services` возвращают единый формат пагинации:
```json
{
"data": [],
"pagination": {
"total": 1,
"count": 1,
"per_page": 50,
"current_page": 1,
"total_pages": 1,
"has_previous_page": false,
"has_next_page": false
}
}
```
Основные query-параметры списков:
- `page` - номер страницы;
- `perPage` - размер страницы;
- `regionId` - фильтр по региону;
- `active` - фильтр активности.
`/article/list` сохраняет старый контракт фронтенда: размер страницы задаётся параметром `limit`, а метаданные возвращаются в ключе `meta`:
```json
{
"data": [],
"meta": {
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
}
```
Пример создания новости:
```bash
curl -X POST http://localhost:8081/news/create \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
--data '{
"id": 900001,
"name": "Локальная новость",
"active": true,
"regionId": 91,
"alias": "local-admin-news",
"anons": "Краткий анонс",
"content": "Полный текст новости"
}'
```
### Интеграции и служебные методы
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `GET` | `/infoclinica/clvisitsovacheckpass/{filial}` | `InfoclinicaController` | Проверка прохода |
| `GET` | `/idoctor/list` | `InfoclinicaController` | Список врачей Infoclinica |
| `POST` | `/reservation/anonymous-reserve` | `InfoclinicaController` | Анонимная запись |
| `POST` | `/calltouch/create-lead` | `CalltouchController` | Создание лида |
| `GET` | `/service/sendmail` | `ServiceController` | Отправка письма |
| `POST` | `/smart-captcha` | `ServiceController` | Проверка captcha |
| `GET` | `/xml/feed` | `XmlFeedController` | XML-фид |
| `GET` | `/xml/feed/v1` | `XmlFeedController` | XML-фид v1 |
## Cabinet API и страницы
### Swagger и внутренний API
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `ANY` | `/api/swagger.json` | `InternalAPIController::swaggerJson` | OpenAPI JSON |
| `ANY` | `/api/swagger` | `InternalAPIController::swaggerUI` | Swagger UI |
| `POST` | `/api/smart-captcha` | `InternalAPIController::smartCaptcha` | Проверка captcha |
| `GET` | `/api/banner/{regionId}` | `InternalAPIController::show` | Баннер региона |
| `POST` | `/api/log` | `InternalAPIController::log` | Логирование |
| `POST` | `/api/count-record` | `InternalAPIController::countRecord` | Количество записей |
| `POST` | `/api/add-record` | `InternalAPIController` | Создание записи |
| `POST` | `/api/msg` | `InternalAPIController` | Сообщение/SMS |
| `POST` | `/api/veretify` | `InternalAPIController` | Проверка SMS-кода |
| `POST` | `/api/search` | `InternalAPIController` | Поиск |
| `GET` | `/api/departments` | `InternalAPIController` | Отделения |
| `POST` | `/api/add-calltouch` | `CalltouchAPIController` | Заявка Calltouch |
### Публичный API cabinet
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `POST` | `/api/anonymous-reserve` | `PublicAPIController` | Анонимная запись |
| `GET` | `/api/interval` | `PublicAPIController` | Интервалы расписания |
| `GET` | `/api/userInfo` | `PublicAPIController` | Информация о пользователе |
| `GET` | `/api/pricelist/departments` | `PublicAPIController` | Группы прайса |
| `GET` | `/api/pricelist` | `PublicAPIController` | Прайс |
| `GET` | `/api/doctor` | `PublicAPIController` | Врач |
| `GET` | `/api/doctors/{region}` | `PublicAPIController` | Врачи региона |
### Страницы cabinet
| Метод | Путь | Контроллер | Назначение |
| --- | --- | --- | --- |
| `ANY` | `/login` | `SecurityController` | Страница входа |
| `ANY` | `/logout` | `SecurityController` | Выход |
| `POST` | `/api/usrlog/logout` | `SecurityController` | API logout |
| `GET/POST` | `/registration` | `SecurityController` | Регистрация |
| `POST` | `/forget` | `SecurityController` | Восстановление |
| `POST` | `/api/authenticated` | `SecurityController` | Авторизация API |
| `GET` | `/` | `DefaultController` | Главная кабинета, требует авторизации |
| `ANY` | `/stoimost-uslug` | `DefaultController` | Стоимость услуг |
| `POST` | `/update/price-list` | `DefaultController` | Обновление прайса |
| `ANY` | `/price-list` | `DefaultController` | Админский прайс |
| `GET` | `/specialists/{alias?}` | `SpecialistController` | Каталог врачей |
| `GET` | `/online-specialists` | `SpecialistController` | Онлайн-врачи |
| `GET` | `/specialist/{alias}` | `SpecialistController` | Карточка врача |
| `ANY` | `/favorites` | `SpecialistController` | Избранное |
+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` | карточка врача |
+154
View File
@@ -0,0 +1,154 @@
# Архитектура
## Общая схема контейнеров
```mermaid
flowchart LR
user[Пользователь / браузер]
nginx[nginx\n:80 / :443]
backend[Backend API\nSymfony 7.3\nphp84:9000]
cabinet[Cabinet legacy\nSymfony 5.4\nphp82:9000]
nextjs[Sovamed site\nNext.js\nnextjs:3001]
static[Static apps\nfrontend/adminPanel/kiosk dist]
pg[(PostgreSQL\npgsql:5432)]
redis[(Redis\nredis:6379)]
bitrix[(Bitrix MySQL)]
mis[Infoclinica / MIS]
sms[SMS / Notisend]
calltouch[Calltouch]
user --> nginx
nginx -->|api.sovamed.ru| backend
nginx -->|cabinet.sovamed.ru| cabinet
nginx -->|sovamed site| nextjs
nginx -->|static dist| static
backend --> pg
backend --> redis
backend --> bitrix
backend --> mis
backend --> sms
backend --> calltouch
cabinet --> pg
cabinet --> redis
cabinet --> bitrix
cabinet --> mis
cabinet --> sms
cabinet --> calltouch
```
## Роли сервисов
- `nginx` - публичная точка входа, TLS, маршрутизация на PHP-FPM и статические файлы.
- `php84` - PHP-FPM для `apps/backend`, рабочая директория `/var/www/backend`.
- `php82` - PHP-FPM для `apps/cabinet`, рабочая директория `/var/www/cabinet`.
- `pgsql` - PostgreSQL, данные монтируются в `infrastructure/pgsql/data`.
- `redis` - Redis с обязательным паролем.
- `nodejs` - helper-контейнер для Node-проектов, в текущем compose работает как долгоживущий контейнер без dev-команды.
- `nextjs` - production-контейнер для `apps/sovamed`, но сами исходники `apps/sovamed` в текущем дереве отсутствуют.
## Потоки данных
Backend и cabinet оба работают с медицинскими справочниками, врачами, филиалами, ценами, отзывами и записями. Оба проекта интегрируются с внешними системами:
- Bitrix;
- Infoclinica/MIS;
- Calltouch;
- SMS/уведомления;
- Yandex/Direct в старом cabinet.
Backend выглядит как новый API-слой и единое хранилище данных. Cabinet является старым монолитом с пользовательским интерфейсом, админскими страницами, публичным API и служебными командами синхронизации.
## Слои приложений
```mermaid
flowchart TB
http[HTTP request]
controller[Controller\nмаршруты, request/response]
dto[DTO / Form\nвалидация входных данных]
service[Service / Bundle\nбизнес-логика и интеграции]
repo[Repository\nзапросы Doctrine]
entity[Entity\nмодель данных]
db[(PostgreSQL / внешние БД)]
external[Внешние API]
http --> controller
controller --> dto
controller --> service
controller --> repo
service --> repo
repo --> entity
repo --> db
service --> external
```
В `backend` этот слой выражен чище: контроллеры тоньше, логика вынесена в сервисы и DTO, роуты объявлены attributes. В `cabinet` часть логики остается внутри контроллеров и локальных `Bundle/*`-классов, потому что это старый монолит с Twig-страницами и админскими сценариями.
## Поток API-запроса backend
```mermaid
sequenceDiagram
participant Client as Клиент
participant Nginx as nginx
participant PHP as php84
participant Symfony as Symfony Kernel
participant Controller as Controller
participant Service as Service
participant DB as PostgreSQL
participant External as Внешний API
Client->>Nginx: HTTPS запрос api.sovamed.ru
Nginx->>PHP: FastCGI /public/index.php
PHP->>Symfony: Request
Symfony->>Controller: route + DI
Controller->>Service: бизнес-операция
Service->>DB: чтение/запись через Repository
Service->>External: при необходимости MIS/Bitrix/SMS
External-->>Service: ответ
DB-->>Service: данные
Service-->>Controller: результат
Controller-->>Client: JsonResponse
```
## Поток страницы cabinet
```mermaid
sequenceDiagram
participant User as Пользователь
participant Nginx as nginx
participant PHP as php82
participant Controller as Symfony Controller
participant Service as Service/Bundle
participant Twig as Twig
participant DB as PostgreSQL
User->>Nginx: HTTPS запрос cabinet.sovamed.ru
Nginx->>PHP: FastCGI /public/index.php
PHP->>Controller: route + security
Controller->>Service: подготовка данных
Service->>DB: Doctrine query
DB-->>Service: сущности/массивы
Service-->>Controller: результат
Controller->>Twig: render template
Twig-->>User: HTML
```
## Где искать код
- HTTP endpoints backend: `apps/backend/src/Controller`.
- HTTP endpoints cabinet: `apps/cabinet/src/Controller`.
- Модели Doctrine: `src/Entity` в каждом приложении.
- Запросы к БД: `src/Repository`.
- Бизнес-логика: `src/Service`.
- Консольные задачи синхронизации: `src/Command`.
- Twig-шаблоны cabinet: `apps/cabinet/templates`.
- Frontend-ассеты cabinet: `apps/cabinet/assets`.
## Следующие страницы
- [Backend: архитектура модулей](./apps/backend-architecture.md)
- [Cabinet: архитектура модулей](./apps/cabinet-architecture.md)
- [Модели данных](./data-model.md)
+474
View File
@@ -0,0 +1,474 @@
# Модели данных
Эта страница описывает основные сущности, найденные в `apps/backend/src/Entity` и `apps/cabinet/src/Entity`. В проектах есть много справочных полей; ниже выделены доменные ядра и связи, которые важны для понимания системы.
## Backend ER-схема
```mermaid
erDiagram
SPECIALIST ||--o{ LOCATION : "id -> specialist_id"
SPECIALIST ||--o{ REVIEW : "id -> specialist_id"
SPECIALIST ||--o{ SPECIALIST_DOCS : "id -> specialist_id"
SPECIALIST ||--o{ SPECIALIST_DCODE_DESCRIPTION : "id -> specialist_id"
SPECIALIST }o--o{ STOCK : "join table"
RECORD ||--o| ALERT_SMS : "id -> record_id"
WIDGET_FORM ||--o{ WIDGET_FORM_INPUT : "id -> widget_form_id"
SPECIALIST {
int id PK
string name
string alias
int region_id
string post
string experience
bool active
bool display_schedule
jsonb dcodes
}
LOCATION {
bigint id PK
int specialist_id FK
bigint dcode
bigint department
int filial
bool online_mode
date nearest_date
}
REVIEW {
int id PK
int specialist_id FK
bool active
date date_create
string author
float rating
string source
int external_id
}
SPECIALIST_DOCS {
int id PK
int specialist_id FK
string name
string picture
bool active
string type
}
SPECIALIST_DCODE_DESCRIPTION {
int id PK
int specialist_id FK
bigint dcode
bigint department
text content
}
STOCK {
int id PK
string name
text content
date start_date
date end_date
}
RECORD {
int id PK
int specialist_id
string phone
datetime create_at
string hash
json reserve
}
ALERT_SMS {
int id PK
int record_id FK
datetime date_create
text response
}
WIDGET_FORM {
int id PK
string name
}
WIDGET_FORM_INPUT {
int id PK
int widget_form_id FK
string text
string type
string bitrix24_id
int sort
}
```
### Backend: перевод сущностей
| Сущность на диаграмме | Русское название | Смысл |
| --- | --- | --- |
| `SPECIALIST` | Врач | Карточка врача в новом API |
| `LOCATION` | Локация приема | Привязка врача к отделению, филиалу и режиму приема |
| `REVIEW` | Отзыв | Отзыв о враче |
| `SPECIALIST_DOCS` | Документ врача | Сертификат/документ/изображение врача |
| `SPECIALIST_DCODE_DESCRIPTION` | Описание врача по `dcode` | Текстовое описание для конкретного кода врача/отделения |
| `STOCK` | Акция | Акция, связанная с врачами через `ManyToMany` |
| `RECORD` | Запись пациента | Локальная запись факта бронирования |
| `ALERT_SMS` | SMS-уведомление | Ответ SMS-провайдера по записи |
| `WIDGET_FORM` | Форма виджета | Конструктор формы |
| `WIDGET_FORM_INPUT` | Поле формы | Поле формы виджета |
### Backend: ключи связей
| Связь | Тип | Ключи |
| --- | --- | --- |
| Врач -> Локация приема | `OneToMany` / `ManyToOne` | `specialist.id = location.specialist_id` |
| Врач -> Отзыв | `OneToMany` / `ManyToOne` | `specialist.id = review.specialist_id` |
| Врач -> Документ врача | `OneToMany` / `ManyToOne` | Doctrine создает `specialist_docs.specialist_id -> specialist.id` |
| Врач -> Описание по dcode | `ManyToOne` на стороне описания | `specialist_dcode_description.specialist_id -> specialist.id` |
| Врач <-> Акция | `ManyToMany` | join-таблица генерируется Doctrine; владелец связи `Stock::$specialist` |
| Запись -> SMS-уведомление | `OneToOne` | Doctrine создает `alert_sms.record_id -> record.id` |
| Форма виджета -> Поле формы | `OneToMany` / `ManyToOne` | Doctrine создает `widget_form_input.widget_form_id -> widget_form.id` |
`Record.specialistId` в backend не является Doctrine-связью с `Specialist`: это логический внешний идентификатор врача, сохраненный как число.
## Backend справочники и контент
```mermaid
classDiagram
class Department {
id
did
name
groupName
onlineMode
alias
active
}
class Filial {
id
fid
name
address
regionId
siteId
company
phone
email
}
class PriceDepartment {
id
name
groupId
doctCount
viewInWeb
}
class PriceList {
id
kodoper
schname
specname
speccode
priceInfo
discprice
filial
groupId
}
class SiteService {
id
name
alias
regionId
anons
content
faq
tags
clinics
}
class Disease {
id
name
alias
regionId
symptom
content
tags
staffList
}
class MedicalCenter {
id
name
alias
regionId
doctors
services
content
}
class Article {
id
name
alias
doctors
services
content
}
class News {
id
name
alias
regionId
content
photos
}
class Promo {
id
name
alias
regionId
clinics
period
}
```
## Cabinet ER-схема
```mermaid
erDiagram
CITY ||--o{ FILIAL : "id -> city_id"
CITY ||--o{ REVIEW_SOURCE : "id -> city_id"
CITY ||--o| BANNER : "id -> city_id"
FILIAL ||--o{ REVIEW_SOURCE : "id -> filial_id"
CATEGORY_PAGE ||--o{ PAGE : "id -> category_id"
RECORD ||--o| ALERT_SMS : "id -> record_id"
WIDGET_FORM ||--o{ WIDGET_FORM_INPUT : "id -> widget_form_id"
CITY {
int id PK
string name
int region_id
int time_zone
}
FILIAL {
int id PK
int city_id FK
int fid
string name
string address
int site_id
bool active
string company
}
REVIEW_SOURCE {
int id PK
int city_id FK
int filial_id FK
string name
int count_row
bool active
float rating
date date_create
}
BANNER {
int id PK
int city_id FK
string href
string src
bool active
}
CATEGORY_PAGE {
int id PK
string name
bool active
}
PAGE {
int id PK
int category_id FK
string name
string alias
text description
bool active
}
RECORD {
int id PK
int specialist_id
string phone
datetime create_at
string hash
json reserve
}
ALERT_SMS {
int id PK
int record_id FK
datetime date_create
text response
}
WIDGET_FORM {
int id PK
string name
}
WIDGET_FORM_INPUT {
int id PK
int widget_form_id FK
string text
string type
string bitrix24_id
int sort
}
```
### Cabinet: перевод сущностей
| Сущность на диаграмме | Русское название | Смысл |
| --- | --- | --- |
| `CITY` | Город | Региональная привязка кабинета |
| `FILIAL` | Филиал | Клиника/филиал, привязанный к городу |
| `REVIEW_SOURCE` | Источник отзывов | Внешний источник рейтинга/отзывов по городу или филиалу |
| `BANNER` | Баннер | Региональный баннер |
| `CATEGORY_PAGE` | Категория страниц | Группа CMS-страниц |
| `PAGE` | Страница | CMS-страница |
| `RECORD` | Запись пациента | Локальная запись факта бронирования |
| `ALERT_SMS` | SMS-уведомление | Ответ SMS-провайдера по записи |
| `WIDGET_FORM` | Форма виджета | Конструктор формы |
| `WIDGET_FORM_INPUT` | Поле формы | Поле формы виджета |
### Cabinet: ключи связей
| Связь | Тип | Ключи |
| --- | --- | --- |
| Город -> Филиал | `OneToMany` / `ManyToOne` | Doctrine создает `filial.city_id -> city.id` |
| Город -> Источник отзывов | `OneToMany` / `ManyToOne` | Doctrine создает `review_source.city_id -> city.id`, `nullable=false` |
| Город -> Баннер | `OneToOne` | Doctrine создает `banner.city_id -> city.id` |
| Филиал -> Источник отзывов | `OneToMany` / `ManyToOne` | Doctrine создает `review_source.filial_id -> filial.id` |
| Категория страниц -> Страница | `OneToMany` / `ManyToOne` | Doctrine создает `page.category_id -> category_page.id`, `nullable=false` |
| Запись -> SMS-уведомление | `OneToOne` | Doctrine создает `alert_sms.record_id -> record.id` |
| Форма виджета -> Поле формы | `OneToMany` / `ManyToOne` | Doctrine создает `widget_form_input.widget_form_id -> widget_form.id` |
`Record.specialistId`, `SpecialistView.dcode`, `LocationView.specialistId`, `PriceList.filial`, `PriceList.groupId`, `PriceList.kodoper` в cabinet используются как логические внешние ключи и фильтры, но не оформлены как Doctrine relations.
## Cabinet представления и справочники
```mermaid
classDiagram
class SpecialistView {
id
name
speciality
category
experience
description
alias
dcode
regionId
kodoper
acceptsDms
}
class LocationView {
id
dcode
department
filial
specialistId
onlineMode
active
nearestDate
}
class PriceDepartment {
id
name
groupId
groupName
doctCount
viewInWeb
}
class PriceList {
id
kodoper
schname
specname
speccode
priceInfo
discprice
filial
groupId
}
class User {
id
email
roles
uid
token
fullName
phone
confirm
createdAt
lastActivityAt
}
class Usrlog {
id
pcode
agent
clientIp
method
createdAt
}
class DirectCompany {
id
name
companyId
city
}
class DirectReport {
id
date
adGroupId
campaignId
adId
impressions
clicks
cost
conversions
}
```
## Общие доменные понятия
- `Specialist` / `SpecialistView` - врач. В `backend` это полноценная сущность с явными связями; в `cabinet` это view-модель для чтения.
- `Location` / `LocationView` - где и как врач принимает.
- `PriceList` и `PriceDepartment` - прайс и группы услуг.
- `Record` - запись пациента, хранит payload бронирования в `reserve`.
- `AlertSms` - SMS-уведомление по записи.
- `Filial` и `Department` - филиалы и отделения.
- `Review` и `ReviewSource` - отзывы и источники отзывов.
- `WidgetForm` и `WidgetFormInput` - динамические формы для виджетов.
## Особенности модели
- В `backend` используются PHP attributes Doctrine, в `cabinet` - annotations.
- Часть связей хранится не как Doctrine relation, а как внешние идентификаторы (`dcode`, `fid`, `did`, `regionId`, `groupId`, `kodoper`). Это важно: не все связи можно увидеть по `ManyToOne`.
- `backend` подключается к нескольким источникам данных: основной PostgreSQL, Bitrix MySQL и базе cabinet.
- `cabinet` содержит view-сущности (`SpecialistView`, `LocationView`), поэтому часть данных, вероятно, приходит из SQL views или синхронизированных таблиц.
+90
View File
@@ -0,0 +1,90 @@
# Документация VitePress
Документация живёт в `docs` (монорепо) и зеркалируется в `k3s-test/sova-docs/` для test-контура.
## Test-контур (деплой)
Собранный сайт доступен на **http://docs.sova.local** (после `/etc/hosts`).
Сборка образа: multistage Dockerfile (`npm run build` → nginx). CI-тег: `docs-v*.*.*-test`.
## Запуск через Docker
### Из папки `docs` (отдельный compose)
```bash
cd docs
docker compose up -d
```
Остановка:
```bash
cd docs
docker compose down
```
Том `vitepress_node_modules` совпадает с файлом `environments/docker-compose.docs.yml` в корне репозитория (общий кеш `node_modules`).
URL: `http://localhost:5173`. Порт совпадает с запуском из корня — **не поднимайте оба варианта одновременно**.
### Из корня репозитория
```bash
make docs
```
Сайт будет доступен на `http://localhost:5173`.
Остановка:
```bash
make docs-down
```
Прямой Docker Compose-вызов из корня:
```bash
docker compose -f environments/docker-compose.docs.yml up -d
```
## Локальный запуск без Docker
```bash
cd docs
npm install
npm run dev
```
После запуска VitePress покажет локальный URL, обычно `http://localhost:5173`.
## Сборка
```bash
cd docs
npm run build
```
Результат сборки появится в `docs/.vitepress/dist`.
## Preview сборки
```bash
cd docs
npm run preview
```
## Как добавлять страницы
1. Создать `.md` файл внутри `docs`.
2. Добавить ссылку в `docs/.vitepress/config.mts` в `sidebar`.
3. Не переносить в документацию реальные секреты из `.env`.
4. Для команд указывать директорию запуска и контейнер, если команда выполняется через Docker.
## Что стоит дописать после получения всех исходников
- страницы по `frontend`, `adminPanel`, `sovamed`, `kiosk`;
- актуальную схему доменов и окружений;
- процесс деплоя через Jenkins;
- список внешних API и контактов владельцев интеграций;
- восстановление локальной БД из дампа.
+74
View File
@@ -0,0 +1,74 @@
# Окружение
## Безопасный локальный контур
Для локальной разработки добавлен отдельный контур, который не использует продовые доступы:
- compose-файл: `environments/docker-compose.local.yml`;
- переменные Docker Compose: `local/.env.local`;
- пример переменных: `local/.env.local.example`;
- локальный backend env: `apps/backend/.env` и `apps/backend/.env.local`;
- локальный cabinet env: `apps/cabinet/.env` и `apps/cabinet/.env.local`;
- seed PostgreSQL: `local/postgres/init`;
- seed локального Bitrix MySQL: `local/mysql-bitrix/init`.
Все подключения направлены только на Docker-сервисы `postgres-local`, `bitrix-mysql-local` и `redis-local`.
```bash
make local-up
make local-seed
make local-down
```
Локальные базы:
- `sova_backend_local` - основная БД backend;
- `sova_cabinet_local` - основная БД cabinet;
- `sova_bitrix_local` - mock БД Bitrix на MySQL.
Внешние интеграции в local env заменены mock URL вроде `http://mock-mis.local`; настоящие продовые URL и токены не используются.
## Корневой `.env`
Корневой `.env` подключается в `Makefile` и передается в Docker Compose через `--env-file`. В нем должны быть значения для:
- `ROOT_DIR` - абсолютный путь проекта на сервере для cron-скриптов;
- `PG_DB`, `PG_USER`, `PG_PASS` - параметры PostgreSQL;
- `REDIS_PASSWORD` - пароль Redis;
- `GF_SECURITY_ADMIN_USER`, `GF_SECURITY_ADMIN_PASSWORD` - учетные данные Grafana.
Не храните реальные значения в документации. Для новых разработчиков лучше завести `.env.example` без секретов.
## Переменные приложений
Внутри `apps/backend` и `apps/cabinet` есть собственные `.env`, `.env.dev`, `.env.test`. Они управляют Symfony-приложениями: подключениями к БД, внешними API, mailer, Redis, JWT и режимом окружения.
Backend использует несколько Doctrine connections:
- `default` - основная PostgreSQL-база;
- `mysql` - Bitrix MySQL;
- `cabinet` - PostgreSQL-база старого кабинета.
Cabinet использует:
- `default` - PostgreSQL;
- `bitrix` - Bitrix MySQL.
## Режимы Compose
`COMPOSE_PROJECT_ENV` влияет на монтирование nginx/PHP-конфигов:
- `dev` - берутся файлы из `infrastructure/nginx/dev` и `php-ini-dev.ini`;
- `prod` - берутся файлы из `infrastructure/nginx/prod` и `php-ini-prod.ini`.
`COMPOSE_NODE_ENV` выставляется в `Makefile`, но в `docker-compose.dev.yml` сейчас явно задан `NODE_ENV=development`.
## Сертификаты
nginx слушает `80` и `443`; HTTPS-конфиги используют файлы из `infrastructure/nginx/certs`. Для разработки есть цель:
```bash
make copy-certs
```
Она копирует сертификаты с удаленного хоста, поэтому требует доступ к соответствующему SSH alias.
+146
View File
@@ -0,0 +1,146 @@
# Потоки данных и сценарии
## Запуск окружения
```mermaid
flowchart TB
make[make dev / make up]
env[.env + .env.local]
networks[docker-compose.networks.yml]
dbs[docker-compose.dbs.yml]
apps[docker-compose.apps.yml]
dev[docker-compose.dev.yml]
monitoring[docker-compose.monitoring.yml]
make --> env
make --> networks
make --> dbs
make --> apps
make --> dev
make -->|make up| monitoring
networks --> public[public-network]
networks --> internal[internal-network]
dbs --> pgsql[pgsql]
dbs --> redis[redis]
apps --> nginx[nginx]
apps --> php84[php84 backend]
apps --> php82[php82 cabinet]
apps --> nextjs[nextjs sovamed]
dev --> nodejs[nodejs helper]
```
## Синхронизация справочников
```mermaid
sequenceDiagram
participant Cron as cron / developer
participant Command as Symfony Command
participant Bitrix as Bitrix / external source
participant MIS as Infoclinica / MIS
participant Service as Domain Service
participant Repo as Repository
participant DB as PostgreSQL
Cron->>Command: php bin/console app:...
Command->>Bitrix: получить контент/врачей/отзывы
Command->>MIS: получить расписание/филиалы/цены
Bitrix-->>Command: external data
MIS-->>Command: external data
Command->>Service: нормализовать и применить
Service->>Repo: upsert/update
Repo->>DB: persist + flush
```
## Получение расписания врача
```mermaid
sequenceDiagram
participant Client
participant Controller as SpecialistController
participant Service as SpecialistService
participant Cache as ScheduleCacheService
participant MIS as InfoclinicaClientService
participant DB as PostgreSQL
Client->>Controller: GET /specialist/schedule
Controller->>Service: getSchedule(...)
Service->>Cache: getCachedSchedule(query)
alt cache hit
Cache-->>Service: cached schedule
else cache miss
Service->>MIS: getSchedule(...)
MIS-->>Service: schedule
Service->>Cache: saveSchedule(...)
end
Service->>DB: при необходимости read/write Schedule
Service-->>Controller: расписание
Controller-->>Client: JSON
```
## Анонимная запись
```mermaid
sequenceDiagram
participant Client
participant API as Controller
participant Service as SpecialistService / Infoclinica Rest
participant MIS as MIS
participant DB as Record Repository
participant SMS as SMS service
Client->>API: POST anonymous-reserve
API->>Service: подготовить reserve payload
Service->>MIS: создать запись
MIS-->>Service: результат записи
Service->>DB: сохранить Record
Service->>SMS: отправить уведомление при необходимости
Service-->>API: success + record data
API-->>Client: JSON
```
## Авторизация backend по JWT
```mermaid
flowchart LR
login[POST /user/login] --> dto[UserLoginDto]
dto --> auth[AuthenticationService]
auth --> user[(User)]
auth --> password[password check]
password --> jwt[JWTTokenManager]
jwt --> response[token + user]
request[Защищенный request] --> firewall[JWT firewall]
firewall --> decoder[JWTDecoderService]
decoder --> currentUser[Current User]
```
## Авторизация cabinet через сессию
```mermaid
flowchart LR
login["POST /login"] --> authenticator["LoginFormAuthenticator"]
authenticator --> provider["User provider by email"]
provider --> user[(User)]
authenticator --> session["Session cookies"]
session --> protected["ROLE_USER pages"]
logout["GET /logout"] --> clear["Удаление cookies"]
```
## Документация VitePress
```mermaid
flowchart LR
makeDocs[make docs]
compose[docker-compose.docs.yml]
node[node:24-alpine]
npm[npm install]
vitepress[VitePress dev server]
browser[http://localhost:5173]
makeDocs --> compose
compose --> node
node --> npm
npm --> vitepress
vitepress --> browser
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+25
View File
@@ -0,0 +1,25 @@
# Form screenshots (local)
Base: http://localhost:8082
| File | Title | URL |
|------|-------|-----|
| 01-login-landing.png | Страница входа (landing) | http://localhost:8082/login |
| 02-login-modal.png | Модалка авторизации | http://localhost:8082/login |
| 03-password-recovery.png | Восстановление пароля | http://localhost:8082/login#recovery |
| 04-registration.png | Регистрация пациента | http://localhost:8082/registration |
| 05-specialists-search.png | Поиск врачей (фильтр + список) | http://localhost:8082/specialists |
| 06-price-search.png | Поиск услуг и цен | http://localhost:8082/stoimost-uslug |
| 07-help-callback.png | Обратная связь (модалка) | http://localhost:8082/help |
| 08-info-reference-entry.png | Информация / справка 3-НДФЛ | http://localhost:8082/info |
| 09-widget-form-2.png | CRM-виджет: вызов врача на дом (id=2) | http://localhost:8082/widget/form/2 |
| 10-widget-reference.png | Виджет справки 3-НДФЛ | http://localhost:8082/widget/reference |
| 11-doctor-your-home-modal.png | Вызов врача на дом (iframe form/2) | http://localhost:8082/doctor-your-home |
| 12-booking-modal-offline.png | Запись на приём (модалка, stub) | http://localhost:8082/specialists |
| 13-online-specialists.png | Онлайн-консультация (список) | http://localhost:8082/online-specialists |
| 14-case-history.png | Приёмы: онлайн запись + оплата (stub) | http://localhost:8082/case-history |
| 15-payment.png | Финансы / счета к оплате (stub) | http://localhost:8082/payment |
| 16-settings.png | Настройки / смена пароля | http://localhost:8082/setting |
| 17-security-card.png | Медицинская карта (stub) | http://localhost:8082/security-card |
| 18-refund-form.png | Возврат средств (онлайн-консультация) | http://localhost:8082/refund?filial=1 |
| 19-booking-modal-online.png | Онлайн-запись (модалка, stub) | http://localhost:8082/online-specialists |
+120
View File
@@ -0,0 +1,120 @@
{
"base": "http://localhost:8082",
"capturedAt": "2026-05-26T15:11:54.233Z",
"items": [
{
"file": "01-login-landing.png",
"title": "Страница входа (landing)",
"url": "http://localhost:8082/login",
"notes": ""
},
{
"file": "02-login-modal.png",
"title": "Модалка авторизации",
"url": "http://localhost:8082/login",
"notes": ""
},
{
"file": "03-password-recovery.png",
"title": "Восстановление пароля",
"url": "http://localhost:8082/login#recovery",
"notes": ""
},
{
"file": "04-registration.png",
"title": "Регистрация пациента",
"url": "http://localhost:8082/registration",
"notes": ""
},
{
"file": "05-specialists-search.png",
"title": "Поиск врачей (фильтр + список)",
"url": "http://localhost:8082/specialists",
"notes": ""
},
{
"file": "06-price-search.png",
"title": "Поиск услуг и цен",
"url": "http://localhost:8082/stoimost-uslug",
"notes": ""
},
{
"file": "07-help-callback.png",
"title": "Обратная связь (модалка)",
"url": "http://localhost:8082/help",
"notes": "#callback modal"
},
{
"file": "08-info-reference-entry.png",
"title": "Информация / справка 3-НДФЛ",
"url": "http://localhost:8082/info",
"notes": ""
},
{
"file": "09-widget-form-2.png",
"title": "CRM-виджет: вызов врача на дом (id=2)",
"url": "http://localhost:8082/widget/form/2",
"notes": ""
},
{
"file": "10-widget-reference.png",
"title": "Виджет справки 3-НДФЛ",
"url": "http://localhost:8082/widget/reference",
"notes": ""
},
{
"file": "11-doctor-your-home-modal.png",
"title": "Вызов врача на дом (iframe form/2)",
"url": "http://localhost:8082/doctor-your-home",
"notes": ""
},
{
"file": "12-booking-modal-offline.png",
"title": "Запись на приём (модалка, stub)",
"url": "http://localhost:8082/specialists",
"notes": ""
},
{
"file": "13-online-specialists.png",
"title": "Онлайн-консультация (список)",
"url": "http://localhost:8082/online-specialists",
"notes": ""
},
{
"file": "14-case-history.png",
"title": "Приёмы: онлайн запись + оплата (stub)",
"url": "http://localhost:8082/case-history",
"notes": ""
},
{
"file": "15-payment.png",
"title": "Финансы / счета к оплате (stub)",
"url": "http://localhost:8082/payment",
"notes": ""
},
{
"file": "16-settings.png",
"title": "Настройки / смена пароля",
"url": "http://localhost:8082/setting",
"notes": ""
},
{
"file": "17-security-card.png",
"title": "Медицинская карта (stub)",
"url": "http://localhost:8082/security-card",
"notes": ""
},
{
"file": "18-refund-form.png",
"title": "Возврат средств (онлайн-консультация)",
"url": "http://localhost:8082/refund?filial=1",
"notes": ""
},
{
"file": "19-booking-modal-online.png",
"title": "Онлайн-запись (модалка, stub)",
"url": "http://localhost:8082/online-specialists",
"notes": ""
}
]
}
+34
View File
@@ -0,0 +1,34 @@
# Sova: документация проекта
Эта документация помогает быстро понять состав исходников, запустить окружение и найти основные точки входа.
## Что находится в репозитории
- `apps/backend` - новое единое хранилище данных и API на Symfony 7.3.
- `apps/adminPanel` - админка на React (врачи, филиалы, акции, контент).
- `apps/cabinet` - старый личный кабинет на Symfony 5.4 с Twig и Webpack Encore.
- `environments` - набор Docker Compose-файлов для сетей, БД, приложений, мониторинга и Jenkins.
- `infrastructure` - Dockerfile и конфигурация nginx, PHP-FPM, PostgreSQL, Redis, Node.js.
- `monitoring` - конфигурация Prometheus, Grafana и дашборды.
- `jenkins` - Jenkins pipelines для backend и cabinet.
- `scripts` - эксплуатационные скрипты: cron, certbot, webhooks.
## С чего начать
1. Прочитать [быстрый старт](./quick-start.md).
2. Проверить [окружение](./environment.md) и наличие всех приложений.
3. Поднять Docker Compose через `make dev`.
4. Открыть страницы по [Backend API](./apps/backend.md) и [Cabinet](./apps/cabinet.md).
## Подробная архитектура
- [Архитектура](./architecture.md) - Mermaid-схемы контейнеров, слоев и request flow.
- [Потоки данных](./flows.md) - запуск, синхронизация, расписание, запись и авторизация.
- [adminPanel: CRUD контента](./apps/admin-panel-content-crud.md) - маршруты, виджеты полей, `apiContent`, валидация без alert (`issues/27-future`).
- [Backend: CRUD для контентных сущностей](./apps/backend-content-crud.md) - `Pagerfanta`, `CrudResponder`, фильтры, sync-сервисы, MR по веткам.
- [adminPanel: обзор](./apps/admin-panel.md) - React, RTK Query, layout, переиспользуемые компоненты.
- [Backend: DDD / бизнес-сущности](./apps/backend-ddd.md) - ограниченные контексты, сущности, контроллеры, сервисы, команды по доменам.
- [Backend: бизнес-сценарии (use cases)](./apps/backend-scenarios/index.md) - пошаговые потоки: Identity, врачи/расписание, запись, синхронизация, интеграции.
- [Backend: архитектура модулей](./apps/backend-architecture.md) - контроллеры, сервисы, команды, async-сообщения.
- [Cabinet: архитектура модулей](./apps/cabinet-architecture.md) - монолит, Twig, формы, bundles-интеграции.
- [Модели данных](./data-model.md) - ER-схемы и основные сущности.
BIN
View File
Binary file not shown.
+527
View File
@@ -0,0 +1,527 @@
---
title: Backend — внешние сервисы и стратегия для test/stage/prod
---
# Backend: внешние сервисы, БД и интеграции
> Разбор всех **сторонних зависимостей** `apps/backend`: что они делают, где используются в коде, и **что делать в тестовом контуре** (K8s) — поднять свой инстанс, поставить заглушку или эмулятор.
>
> Связанные документы: [K8s + Terraform + ArgoCD + Gitea](./k8s-cicd-platform-plan.md), локальный контур (`local/`, см. корень репозитория), [бизнес-сценарии](../apps/backend-scenarios/index.md).
---
## 0. Сводная матрица решений (test-контур)
| # | Зависимость | Тип | Где в коде | Test-контур | Stage | Prod |
|---|-------------|-----|------------|-------------|-------|------|
| 1 | PostgreSQL (основная) | БД | `DATABASE_URL`, Doctrine ORM | **Новый инстанс** (1×) | 1× на stage-сервере | **HA: primary + sync replica** (§7.6 плана) |
| 2 | PostgreSQL (cabinet) | БД | `DATABASE_CABINET_URL`, `UsrlogController` | **Новый инстанс** + seed | Отдельный | Prod |
| 3 | MySQL (Bitrix CMS) | БД | `DATABASE_BITRIX_URL`, `BitrixService`, SQL views | **Новый инстанс** + seed из `local/mysql-bitrix` | Отдельный | Prod Bitrix |
| 4 | Redis | Кеш/сессии | `REDIS_URL` | **Новый инстанс** в K8s | Отдельный | Prod Redis |
| 5 | MIS / Инфоклиника (HTTP) | API | `MIS_URL`, `InfoclinicaClientService` | **Mock-сервис** (WireMock / свой stub) | Test MIS или read-only prod | Prod MIS |
| 6 | Widget API (`widget.sovamed.ru`) | API | Hardcoded в Upload*Command | **Mock** или `MIS_URL` после рефакторинга | Stage widget | Prod |
| 7 | Bitrix site (HTTP, картинки) | API | `BITRIX_URL`, `BitrixClientService` | **Mock** (static files) или seed URLs | Stage | Prod |
| 8 | SMS (sms.ru, sms4b) | API | `SMSRU_*`, `SMS4B_*` | **Заглушка** (noop) | Заглушка | **Реальный** провайдер |
| 9 | Почта | API | `MAILER_DSN`, `SendMailService` | **Mailpit / null://null** | Mailpit или sandbox SMTP | Prod SMTP |
| 10 | Yandex SmartCaptcha | API | `SMARTCAPTCHA_*`, `ServiceController` | **Заглушка** (always OK) | Заглушка | **Реальный** ключ |
| 11 | Calltouch | API | `CT_*`, `CalltouchController` | **Заглушка** (noop, уже частично) | Заглушка | **Реальный** API |
| 12 | Bitrix24 | — | `BITRIX24_URL` в `.env` | **Не используется в коде** — игнор | — | При появлении интеграции |
| 13 | JWT / AES / Lock | Локально | env + файлы ключей | **Свои ключи** test | Свои | Prod secrets |
| 14 | Symfony Messenger | Очередь | `MESSENGER_TRANSPORT_DSN` | `doctrine://` или `sync://` | doctrine/redis | prod queue |
```mermaid
flowchart TB
subgraph backend [backend pod]
API[Symfony API]
CMD[Console / CronJobs]
end
subgraph own_test [Поднимаем в test — свои инстансы]
PG[(PostgreSQL main)]
PGC[(PostgreSQL cabinet)]
MY[(MySQL Bitrix)]
RD[(Redis)]
end
subgraph stubs [Заглушки в test]
MIS_M[Mock MIS / Widget API]
SMS_M[Noop SMS]
MAIL_M[Mailpit]
CAP_M[Mock SmartCaptcha]
CT_M[Noop Calltouch]
BTX_M[Mock Bitrix HTTP images]
end
subgraph prod_only [Только prod / stage по решению]
MIS_P[Реальная Инфоклиника]
SMS_P[sms.ru / sms4b]
CAP_P[Yandex SmartCaptcha]
CT_P[Calltouch]
end
API --> PG
API --> PGC
API --> MY
API --> RD
API --> MIS_M
API --> SMS_M
API --> MAIL_M
API --> CAP_M
API --> CT_M
API --> BTX_M
CMD --> PG
CMD --> MY
CMD --> MIS_M
MIS_P -.->|stage/prod| API
SMS_P -.->|prod| API
```
---
## 1. Базы данных (с БД — новый инстанс)
### 1.1. PostgreSQL — основная (`DATABASE_URL`)
**Назначение:** все Doctrine-сущности backend (`User`, `Specialist`, `News`, `Schedule`, …).
**Где используется:**
- `config/packages/doctrine.yaml` → connection `default`
- все Repository, миграции `apps/backend/migrations`
- кеш расписания (`Schedule` в таблице, не Redis)
- Symfony Messenger при `MESSENGER_TRANSPORT_DSN=doctrine://default`
**Test:** Helm Bitnami PostgreSQL / CNPG `instances: 1` в `sova-data-test`.
**Stage:** один PG на **отдельном stage-сервере**.
**Prod:** Patroni (2 db-VM) + **witness etcd на отдельной VM** (не prod-app). Подключение: `DATABASE_URL` → PgBouncer **:6432** (transaction, API); migrate Job и Messenger → **:5432** (session). [§7.6 плана](./k8s-cicd-platform-plan.md).
**Stage/Prod:** отдельные credentials; **никогда** не указывать prod URL в test.
---
### 1.2. PostgreSQL — cabinet (`DATABASE_CABINET_URL`)
**Назначение:** read-only доступ к legacy-таблицам cabinet (не Doctrine entities backend).
**Где используется:**
- `config/packages/doctrine.yaml` → connection `cabinet`
- `UsrlogController``SELECT` из `public.usrlog` через `doctrine.dbal.cabinet_connection`
**Test:** отдельный PostgreSQL (можно второй database на том же chart), seed из `local/postgres/init/03-cabinet-schema-and-seed.sql`.
**Если usrlog не нужен в test:** connection можно оставить на пустую БД; эндпоинт `/usrlog/list` вернёт пустой список.
---
### 1.3. MySQL — Bitrix CMS (`DATABASE_BITRIX_URL`)
**Назначение:** чтение таблиц Bitrix (`b_iblock_*`, `b_file`) для синхронизации контента и отзывов.
**Где используется:**
| Компонент | Как |
|-----------|-----|
| `BitrixService` | прямые SQL через `doctrine.dbal.mysql_connection` |
| `BitrixUpdateReviewsCommand` | `getReviews()` — отзывы по `specialist.id` |
| `BitrixUpdateDoctorsCommand` | нормализация `dcodes` (Bitrix HTTP закомментирован) |
| `*CrudService::syncFromView*` | `INSERT … SELECT FROM public.view_*` |
| Миграции | views `view_article`, `view_news`, … с JOIN на `b_*` (через **mysql_fdw** на prod или упрощённые views в test) |
**Test:** **новый MySQL** в K8s (Bitnami MySQL chart), init SQL из `local/mysql-bitrix/init/` в корне репозитория. Дополнительно — создать упрощённые `view_news`, `view_promo`, … или наполнять таблицы `news`/`promo` через CRUD API без sync-команд.
**Важно:** на prod PG использует **mysql_fdw** (`infrastructure/pgsql` + extensions). В test можно:
- **Вариант A (рекомендуется):** mysql_fdw в test-PG → test-MySQL (повторяет prod);
- **Вариант B:** отключить sync-команды в test CronJobs, контент только через adminPanel CRUD;
- **Вариант C:** materialized views только на PG без FDW (дублировать seed в PG).
---
### 1.4. Redis (`REDIS_URL`)
**Назначение:** Symfony cache, sessions (prod php.ini), потенциально Messenger.
**Test:** Bitnami Redis в `sova-data-test`, свой пароль в SealedSecret.
---
## 2. HTTP-интеграции
### 2.1. MIS / Инфоклиника (`MIS_URL` → `InfoclinicaClientService`)
**Prod URL (пример):** `https://widget.sovamed.ru`
**Эндпоинты клиента:**
| Метод | Путь | Сценарий |
|-------|------|----------|
| GET | `/api/reservation/intervals?{query}` | Расписание врача |
| GET | `/filials/list` | Список филиалов (через client) |
| POST | `/api/reservation/anonymous-reserve` | Анонимная запись |
**Где вызывается:**
- `GetScheduleMessageHandler``getSchedule()`
- `GetAnonymousReserveRequestMessageHandler``anonymousReserve()`
- `UploadDoctorsCommand`, `UploadDepartmentsCommand``$client->request('GET', …)` через `InfoclinicaClientServiceInterface`
**Test — рекомендация: Mock-сервис в K8s**
Развернуть **WireMock** или лёгкий **Node/Go stub** в namespace `sova-mocks`:
```text
MIS_URL=http://mis-mock.sova-mocks.svc.cluster.local
```
Stub должен отдавать JSON в формате, который ждёт `InfoclinicaClientService::normalizeSchedule()` (см. [schedule-cache.md](../apps/backend-scenarios/schedule-cache.md)).
**Альтернативы:**
| Вариант | Когда |
|---------|-------|
| WireMock + JSON fixtures | Полный контроль, воспроизводимые тесты |
| Отдельный «test MIS» у вендора | Если Infoclinica даёт sandbox (уточнить у владельца интеграции) |
| Read-only прокси на prod | **Не рекомендуется** — риск записи/нагрузки |
**Stage:** sandbox MIS или изолированный контур клиники.
---
### 2.2. Widget API (hardcoded `https://widget.sovamed.ru`)
**Проблема:** часть команд **не читает `MIS_URL`**, а хардкодит URL:
| Команда | Путь |
|---------|------|
| `UploadFilialsCommand` | `GET /filials/list` |
| `UploadPriceCommand` | `GET /pricelist/list` |
| `UploadPriceDepCommand` | `GET /pricelist/departments` |
**Test:**
1. **Краткосрочно:** тот же mock-сервис, что для MIS, + **рефакторинг команд** — вынести `base_uri` в env `WIDGET_API_URL` или переиспользовать `MIS_URL`.
2. **До рефакторинга:** mock должен слушать на URL, который прописан в коде (или патч через `/etc/hosts` + Ingress) — **технический долг**.
---
### 2.3. Bitrix site HTTP (`BITRIX_URL` → `BitrixClientService`)
**Prod URL (пример):** `https://sovamed.ru`
**Назначение:** скачивание файлов врачей с путей вида `/upload/iblock/...`.
**Где используется:**
- `GetSpecialistPictureMessageHandler``getSpecialistImage($path)`
**Test:**
| Вариант | Описание |
|---------|----------|
| **Mock HTTP** | nginx/static с несколькими `.jpg` в `/upload/iblock/` |
| **Отключить handler** | Если в test seed картинки уже локальные (`specialist/xxx.jpg`) |
| **Прокси на stage Bitrix** | Только read, без записи |
`BITRIX_URL=http://bitrix-mock.sova-mocks.svc.cluster.local`
---
### 2.4. SMS — sms.ru и sms4b (`SMSRU_*`, `SMS4B_*`)
**Клиенты:** `SmsruClientService`, `Sms4bClientService` (реализуют `SmsClientServiceInterface`).
**Статус в коде:** сервисы **зарегистрированы** в `config/services.yaml`, но **ни один контроллер/команда их пока не inject'ит**. Сущности `Record` / `AlertSms` есть, логика отправки SMS **не подключена** к HTTP-сценариям.
**Test (по вашему решению): заглушка**
```yaml
# Symfony: test env — noop implementation
App\Service\Client\Interfaces\SmsClientServiceInterface:
class: App\Service\Client\Stub\NoopSmsClientService
```
`NoopSmsClientService` пишет в log «SMS suppressed» и возвращает `{ "status": "ok", "stub": true }`.
**Prod:** реальные `SMSRU_URL` / `SMS4B_URL` и токены из SealedSecret.
**Stage:** заглушка или отдельный «sandbox»-аккаунт провайдера с whitelist номеров.
---
### 2.5. Почта (`MAILER_DSN`, `SendMailService`)
**Где используется:**
- `ServiceController::sendmail` — отправка по query-параметрам, защита `MAILER_ACCESS_TOKEN`
- Symfony `MailerInterface``SendMailService`
**Prod `.env`:** `MAILER_DSN=null://null` (письма фактически не уходят, но endpoint есть).
**Test — рекомендация:**
| DSN | Зачем |
|-----|-------|
| `smtp://mailpit.sova-mocks:1025` | UI на :8025, видно письма в браузере |
| `null://null` | Если endpoint `/service/sendmail` не тестируете |
**Безопасность Mailpit Web UI:**
Mailpit UI (`:8025`) **не выставлять в интернет без защиты**. В тестовых письмах могут быть ФИО, mock-пароли, токены сброса (иногда валидные и на stage).
```yaml
# Ingress mailpit.test.sova.dev — только Basic Auth
metadata:
annotations:
nginx.ingress.kubernetes.io/auth-type: basic
nginx.ingress.kubernetes.io/auth-secret: mailpit-basic-auth
nginx.ingress.kubernetes.io/auth-realm: "Mailpit test only"
```
Альтернатива: UI **только cluster-internal** (`kubectl port-forward`), SMTP `:1025` — из `sova-test` по Cluster DNS без Ingress.
**Stage:** Mailpit или корпоративный sandbox SMTP.
**Prod:** реальный SMTP / API (SendGrid, Yandex 360, …).
---
### 2.6. Yandex SmartCaptcha (`SMARTCAPTCHA_URL`, `SMARTCAPTCHA_KEY`)
**Где используется:**
- `ServiceController::smartCaptcha``POST /smart-captcha`
- `AnonymousReserveRequestDto::$captcha` — поле обязательно при записи (валидация на backend)
**Test — заглушка:**
Mock возвращает `{ "status": "ok", "message": "" }` для любого token (как Yandex при успехе).
Или Symfony binding:
```yaml
# when@test
App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface:
class: App\Service\Client\Stub\AlwaysValidSmartCaptchaClientService
```
**Prod:** реальный `https://smartcaptcha.yandexcloud.net` + prod key.
**Связь с MIS mock:** при `anonymous-reserve` captcha проверяется на backend **до** вызова MIS; mock captcha достаточен для E2E test.
---
### 2.7. Calltouch (`CT_URL`, `CT_PARAMS`)
**Где используется:**
- `CalltouchController::createLead` — вызов **`CalltouchClientService::requestCreate` закомментирован**
- Клиент готов: `POST /lead-service/v1/api/request/create`
**Test — заглушка:**
- Оставить как сейчас (echo request) **или**
- `NoopCalltouchClientService` возвращает `{ "data": { "leadId": "test-123" } }`
**Prod/Stage (маркетинг):** реальные `CT_URL` + region tokens из `CT_PARAMS`.
---
### 2.8. Bitrix24 (`BITRIX24_URL`)
**Статус:** переменная есть в `.env`, **использований в PHP-коде backend не найдено**. Можно не задавать в test до появления интеграции.
---
## 3. Локальная инфраструктура (не внешние SaaS)
| Переменная | Назначение | Test |
|------------|------------|------|
| `JWT_*` | Lexik JWT auth | Сгенерировать отдельную пару ключей для test |
| `AES_SECRET_KEY` | `AESCryptService` (сущность `Record`) | Случайный ключ 32 байта в Secret |
| `LOCK_DSN` | Symfony Lock | `flock://` или `postgresql://…` advisory lock |
| `MESSENGER_TRANSPORT_DSN` | Очередь | `doctrine://default` (таблица messenger_messages) |
| `CORS_ALLOW_ORIGIN` | adminPanel origins | `https://admin.test.sova.dev` |
| `API_BASE_URL` | Self-reference / XML feeds | `https://api.test.sova.dev` |
| `MAILER_ACCESS_TOKEN` | Защита `/service/sendmail` | Случайный UUID в Secret |
---
## 4. Эталон: что уже сделано локально (`local/.env.local`)
В `apps/backend/.env.local` для `sova-local` уже задан паттерн **mock URL**:
```dotenv
MIS_URL=http://mock-mis.local
BITRIX_URL=http://mock-bitrix.local
SMS4B_URL=http://mock-sms4b.local
MAILER_DSN=null://null
SMARTCAPTCHA_URL=http://mock-smartcaptcha.local
```
**Для K8s test** нужно не просто «мёртвые» hostname, а **реально поднятые mock-поды** + DNS внутри кластера (Service names).
---
## 5. Архитектура mock-слоя в Kubernetes (test)
```mermaid
flowchart LR
subgraph sova_test [namespace sova-test]
BE[backend]
end
subgraph sova_mocks [namespace sova-mocks]
MIS[mis-mock :8080]
BTX[bitrix-http-mock :8080]
CAP[captcha-mock :8080]
CT[calltouch-mock :8080]
MP[mailpit :8025]
end
subgraph sova_data [namespace sova-data-test]
PG[(PostgreSQL)]
MY[(MySQL Bitrix)]
RD[(Redis)]
end
BE --> PG
BE --> MY
BE --> RD
BE --> MIS
BE --> BTX
BE --> CAP
BE --> CT
BE --> MP
```
### 5.1. Рекомендуемые Helm releases (test)
| Release | Chart / образ | Назначение |
|---------|---------------|------------|
| `mis-mock` | WireMock / custom | расписание, запись, filials, pricelist |
| `bitrix-http-mock` | nginx + static | `/upload/iblock/*.jpg` |
| `captcha-mock` | tiny HTTP server | `POST /validate` → ok |
| `calltouch-mock` | WireMock | lead create |
| `mailpit` | axllent/mailpit | SMTP + Web UI (**Basic Auth** на Ingress) |
| `postgresql-test` | bitnami/postgresql | main + cabinet DBs |
| `mysql-bitrix-test` | bitnami/mysql | Bitrix tables seed |
| `redis-test` | bitnami/redis | cache |
Namespace **`sova-mocks`** общий для всех контуров test (не prod).
### 5.2. Env backend test (фрагмент SealedSecret)
```yaml
DATABASE_URL: postgresql://sova_test:***@postgresql-test.sova-data-test:5432/sova_backend_test
DATABASE_CABINET_URL: postgresql://sova_test:***@postgresql-test.sova-data-test:5432/sova_cabinet_test
DATABASE_BITRIX_URL: mysql://bitrix_test:***@mysql-bitrix-test.sova-data-test:3306/sova_bitrix_test
REDIS_URL: redis://:***@redis-test.sova-data-test:6379/0
MIS_URL: http://mis-mock.sova-mocks.svc.cluster.local:8080
BITRIX_URL: http://bitrix-http-mock.sova-mocks.svc.cluster.local:8080
SMARTCAPTCHA_URL: http://captcha-mock.sova-mocks.svc.cluster.local:8080
SMARTCAPTCHA_KEY: test-key-not-used-by-mock
SMSRU_URL: http://noop.invalid
SMS4B_URL: http://noop.invalid
CT_URL: http://calltouch-mock.sova-mocks.svc.cluster.local:8080
MAILER_DSN: smtp://mailpit.sova-mocks.svc.cluster.local:1025
MAILER_ACCESS_TOKEN: "<uuid>"
CORS_ALLOW_ORIGIN: https://admin.test.sova.dev
```
При внедрении **NoopSmsClientService** URL sms можно не резолвить.
---
## 6. CronJobs и внешние вызовы
| CronJob (из `scripts/cron.*`) | Внешние зависимости | Test |
|-------------------------------|---------------------|------|
| `upload:deps`, `upload:doctors` | MIS HTTP | mock MIS или отключить |
| `upload:filials`, `upload:price*` | widget HTTP (hardcoded) | mock + рефакторинг URL |
| `bitrix-update-reviews` | MySQL Bitrix | test MySQL + seed |
| `bitrix-update-doctors` | только PG | OK |
| `upload:news`, `upload:promo`, … | PG views → MySQL | test MySQL + views или CRUD only |
| `ClearScheduleCacheCommand` | только PG | OK |
**Рекомендация для первого выката test:** включить CronJobs только для **очистки кеша расписания**; sync-команды — после готовности mock MIS и Bitrix MySQL.
**concurrencyPolicy в K8s:**
Для всех sync CronJobs обязательно:
```yaml
spec:
concurrencyPolicy: Forbid # не запускать вторую копию, пока первая не завершилась
jobTemplate:
spec:
activeDeadlineSeconds: 3600
```
Без `Forbid` при зависшей БД `bitrix-update-reviews` через час стартует дубликат — параллельные job'ы начнут мешать друг другу.
---
## 7. Stage vs Prod — отличия от test
| Сервис | Test | Stage | Prod |
|--------|------|-------|------|
| PostgreSQL / Redis / Bitrix MySQL | Изолированные test DB | Изолированные stage DB | Production DB |
| MIS | Mock | Sandbox MIS **или** mock | Production MIS |
| SMS | Noop | Noop или sandbox | Live |
| Mail | Mailpit | Sandbox SMTP | Live |
| SmartCaptcha | Mock | Mock | Live |
| Calltouch | Noop | Noop | Live |
| Bitrix HTTP (images) | Mock | Stage Bitrix site | Prod |
---
## 8. Технический долг (сделать до/во время миграции)
1. **Hardcoded `widget.sovamed.ru`** в `UploadFilialsCommand`, `UploadPriceCommand`, `UploadPriceDepCommand` → env `WIDGET_API_URL` или `MIS_URL`.
2. **Hardcoded `https://api.sovamed.ru`** в `XmlFeedGenerator*` → env `API_PUBLIC_URL`.
3. **Реализовать или явно отключить** SMS-клиенты (`NoopSmsClientService` + bind по env).
4. **Calltouch** — раскомментировать с env-флагом `CALLTOUCH_ENABLED=false` в test.
5. **BITRIX24_URL** — удалить из `.env` или документировать при появлении кода.
6. **Mock-сервисы** — вынести JSON fixtures в репозиторий `sova-mocks` (рядом с `sova-deploy`).
---
## 9. Чек-лист готовности интеграций test-контура
- [ ] PostgreSQL test: миграции backend применены
- [ ] MySQL Bitrix test: seed + (опционально) mysql_fdw views
- [ ] Redis test: ping из backend pod
- [ ] MIS mock: `GET /api/reservation/intervals` возвращает валидный JSON
- [ ] MIS mock: `POST /api/reservation/anonymous-reserve` → 200
- [ ] Captcha mock: `POST /validate` → ok
- [ ] Mailpit: письмо из `/service/sendmail` видно в UI (Basic Auth или port-forward)
- [ ] SMS: noop binding, приложение стартует без sms.ru
- [ ] Calltouch: lead endpoint не бьёт prod
- [ ] CORS: admin.test.sova.dev → api.test.sova.dev
- [ ] JWT keys: login работает
- [ ] CronJobs sync: `concurrencyPolicy: Forbid`
- [ ] CronJobs: только безопасные включены на первом выкате
---
## 10. Связанные файлы в репозитории
| Путь | Содержимое |
|------|------------|
| `apps/backend/.env` | prod-like переменные (образец) |
| `apps/backend/.env.local` | local mock URLs |
| `apps/backend/config/services.yaml` | DI клиентов |
| `apps/backend/config/packages/doctrine.yaml` | 3 DB connection |
| `apps/backend/src/Service/Client/*` | HTTP-клиенты |
| `apps/backend/src/Service/Bitrix/BitrixService.php` | MySQL Bitrix |
| `local/mysql-bitrix/init/` | seed Bitrix tables |
| `local/postgres/init/` | seed PG backend + cabinet |
| `monitoring/prometheus/prometheus.yml` | scrape Bitrix server `192.168.2.11` (prod-only) |
---
*Версия 1.0. При изменении интеграций обновлять матрицу в §0 и env-фрагмент в §5.2.*
+73
View File
@@ -0,0 +1,73 @@
# Docker Compose
Compose-конфигурация разделена на несколько файлов в `environments`.
## Файлы
- `docker-compose.networks.yml` - `public-network` и `internal-network`.
- `docker-compose.dbs.yml` - PostgreSQL и Redis.
- `docker-compose.apps.yml` - nginx, `php84`, `php82`, `nextjs`.
- `docker-compose.dev.yml` - Node.js helper-контейнер для разработки frontend-приложений.
- `docker-compose.monitoring.yml` - node-exporter, php-fpm-exporter, Prometheus, Grafana.
- `docker-compose.jenkins.yml` - Jenkins.
## Make targets
```bash
make help
```
Основные цели:
- `make dev` - dev-режим без мониторинга;
- `make up` - prod-режим с мониторингом;
- `make down` - остановка сервисов;
- `make restart` - остановка и повторный запуск;
- `make clean` - остановка и удаление volumes;
- `make prune` - полная очистка Docker;
- `make chown-up` - выставить владельцев для мониторинга и PostgreSQL data;
- `make chown-1000` - вернуть владельца `1000:1000`;
- `make fix-certs` - поправить права сертификатов nginx;
- `make rebuild-nextjs` - пересобрать и перезапустить Next.js-контейнер.
## Ручной запуск compose
Эквивалент `make dev`:
```bash
COMPOSE_PROJECT_ENV=dev COMPOSE_NODE_ENV=development \
docker compose --env-file .env \
-f environments/docker-compose.networks.yml \
-f environments/docker-compose.dbs.yml \
-f environments/docker-compose.apps.yml \
-f environments/docker-compose.dev.yml \
up -d
```
Эквивалент `make up`:
```bash
COMPOSE_PROJECT_ENV=prod COMPOSE_NODE_ENV=production \
docker compose --env-file .env \
-f environments/docker-compose.networks.yml \
-f environments/docker-compose.dbs.yml \
-f environments/docker-compose.apps.yml \
-f environments/docker-compose.dev.yml \
-f environments/docker-compose.monitoring.yml \
up -d
```
## Порты
- `80`, `443` - nginx.
- `5432` - PostgreSQL.
- `3001` - Next.js `sovamed`.
- `3000` - Grafana.
- `9090` - Prometheus.
- `8080`, `50000` - Jenkins.
## Volumes и данные
PostgreSQL хранит данные в `infrastructure/pgsql/data`, Redis - в `infrastructure/redis/data`. Эти директории игнорируются git и не должны удаляться без необходимости.
`make clean` удаляет docker volumes, а `make prune` чистит Docker глобально на машине.
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
# Test-контур k3s
Документация по песочнице `k3s-test/`: локальное развёртывание на Multipass/k3s, CI/CD через Gitea, GitOps через ArgoCD.
**Живой сайт в test-контуре:** http://docs.sova.local
## Разделы
- [Test-контур: что сделано и перенос на сервер](./test-contour-article) — полная статья
- [Система тегов CI/CD](./tags) — формат тегов и release-скрипт
- [ArgoCD: sova-root и data-test](./argocd-apps) — зачем эти приложения и почему «нельзя войти»
Исходники: репозиторий `sova/docs` в Gitea, локально — `k3s-test/sova-docs/`.
@@ -71,7 +71,7 @@ flowchart TB
| `sova-backend/` | Копия Symfony API + Dockerfile + workflow Gitea |
| `sova-adminpanel/` | Копия React admin + runtime `env.js` |
| `sova-cabinet/` | Копия Symfony ЛК + Dockerfile + workflow Gitea |
| `sova-docs/` | Документация (Docsify) + Dockerfile + workflow Gitea |
| `sova-docs/` | VitePress-документация (из `docs/` монорепо) + Dockerfile + workflow Gitea |
| `sova-deploy/` | Helm charts, ArgoCD manifests, SQL для test-БД |
| `sova-mocks/` | WireMock (MIS, Calltouch, Captcha) + Mailpit |
| `sova-platform/` | Terraform-модуль установки k3s на VM |
+3 -3
View File
@@ -5,10 +5,10 @@ server {
index index.html;
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ $uri/index.html /index.html;
}
location ~* \.(md|html|css|js|json)$ {
add_header Cache-Control "no-cache";
location ~* \.(html|css|js|json|svg|woff2?|png|jpg|jpeg|gif|ico)$ {
add_header Cache-Control "public, max-age=3600";
}
}
+88
View File
@@ -0,0 +1,88 @@
# Эксплуатация
## Мониторинг
`make up` подключает мониторинг:
- Prometheus на `:9090`;
- Grafana на `:3000`;
- node-exporter;
- php-fpm-exporter для `php82` и `php84`.
Дашборды Grafana лежат в `monitoring/grafana/provisioning/dashboards`, datasource Prometheus - в `monitoring/grafana/provisioning/datasources/prometheus.yaml`.
## Cron-задачи
В `scripts` есть два основных cron-скрипта:
- `cron.hourly.sh` - обновление врачей;
- `cron.oncyday.sh` - обновление цен и отзывов.
Установка cron:
```bash
make cron-setup
```
Перед запуском проверьте `ROOT_DIR` в корневом `.env`: Makefile использует этот путь при записи задач в crontab.
## Сертификаты
Получение сертификатов:
```bash
scripts/certbot.sh
```
Исправление прав:
```bash
make fix-certs
```
После изменения сертификатов перезапустите nginx:
```bash
docker restart nginx
```
## Jenkins
Jenkins описан в `environments/docker-compose.jenkins.yml` и использует pipelines из `jenkins/pipelines`.
Запуск вручную:
```bash
docker compose --env-file .env \
-f environments/docker-compose.networks.yml \
-f environments/docker-compose.jenkins.yml \
up -d
```
Порты:
- `8080` - UI;
- `50000` - agent port.
## Диагностика
Проверка контейнеров:
```bash
docker ps
```
Логи nginx и PHP:
```bash
docker logs nginx --tail=100
docker logs php84 --tail=100
docker logs php82 --tail=100
```
Проверка PHP-FPM конфигурации:
```bash
docker exec -it php84 php-fpm --test
docker exec -it php82 php-fpm --test
```
+3748
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "sova-onboarding-docs",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev . --host 0.0.0.0",
"build": "vitepress build .",
"preview": "vitepress preview . --host 0.0.0.0"
},
"devDependencies": {
"mermaid": "^11.15.0",
"vitepress": "latest"
}
}
+149
View File
@@ -0,0 +1,149 @@
# Быстрый старт
## Требования
- Docker и Docker Compose v2.
- Доступ к приватным исходникам всех приложений, если нужны `frontend`, `adminPanel`, `sovamed` или `kiosk`.
- Свободные порты `80`, `443`, `5432`, `3000`, `3001`, `8080`, `9090` в зависимости от запускаемого набора сервисов.
- Локальный `.env` в корне репозитория.
## Запуск dev-окружения
Для безопасной локальной разработки используйте отдельный local-контур. Он не читает корневой `.env`, не использует продовые домены и поднимает отдельные контейнеры:
```bash
make local-up
```
Локальные адреса:
- backend: `http://localhost:8081`;
- cabinet: `http://localhost:8082`;
- PostgreSQL: `localhost:15432`;
- Bitrix MySQL mock: `localhost:13306`;
- Redis: `localhost:16379`.
Остановка:
```bash
make local-down
```
Остановка с удалением локальных volumes:
```bash
make local-clean
```
Повторное наполнение локальных БД тестовыми данными:
```bash
make local-seed
```
Сборка ассетов cabinet. Нужна, если `http://localhost:8082/login` отдает `500` с ошибкой про `public/build/entrypoints.json`:
```bash
make local-assets
```
Проверка, что локальный контур работает:
```bash
make local-smoke
```
Исторические команды ниже используют общий корневой `.env`. Не запускайте их для локальной разработки, если есть риск обращения к внешним окружениям.
Из корня репозитория:
```bash
make dev
```
Команда собирает и запускает:
- сети из `environments/docker-compose.networks.yml`;
- PostgreSQL и Redis из `environments/docker-compose.dbs.yml`;
- nginx, PHP-FPM и Next.js из `environments/docker-compose.apps.yml`;
- Node.js helper-контейнер из `environments/docker-compose.dev.yml`.
Фактически `make dev` вызывает `docker compose` с `COMPOSE_PROJECT_ENV=dev` и `COMPOSE_NODE_ENV=development`.
## Запуск полного набора
```bash
make up
```
Этот сценарий использует production-переменные окружения и дополнительно подключает мониторинг из `environments/docker-compose.monitoring.yml`.
## Остановка
```bash
make down
```
Полная остановка с удалением volumes:
```bash
make clean
```
Полная очистка Docker на машине:
```bash
make prune
```
## Команды внутри контейнеров
Установка PHP-зависимостей backend:
```bash
docker exec -it php84 composer install
```
Установка PHP-зависимостей cabinet:
```bash
docker exec -it php82 composer install
```
Установка и сборка frontend-ассетов cabinet:
```bash
docker exec -it php82 yarn install
docker exec -it php82 yarn dev
```
Миграции backend:
```bash
docker exec -it php84 php bin/console doctrine:migrations:migrate
```
Миграции cabinet:
```bash
docker exec -it php82 php bin/console doctrine:migrations:migrate
```
## Локальные домены
nginx-конфиги ожидают домены вроде:
- `api.sovamed.ru` -> `apps/backend/public`;
- `cabinet.sovamed.ru` -> `apps/cabinet/public`;
- `dev.sovamed.ru`, `dev.wmtmed.ru`, `adm.sovamed.ru` и другие домены для отсутствующих сейчас приложений.
Для локальной разработки обычно нужно добавить домены в `/etc/hosts` на `127.0.0.1` и иметь dev-сертификаты в `infrastructure/nginx/certs`.
## Проверка состояния
```bash
docker compose ps
docker logs nginx --tail=100
docker logs php84 --tail=100
docker logs php82 --tail=100
```
-22
View File
@@ -1,22 +0,0 @@
# Документация test-контура Sova
Статический сайт с инструкциями по песочнице `k3s-test`: развёртывание, CI/CD, GitOps и перенос на удалённый сервер.
## Быстрые ссылки
| URL | Сервис |
|-----|--------|
| http://docs.sova.local | эта документация |
| http://api.test.sova.local | backend API |
| http://admin.test.sova.local | admin panel |
| http://cabinet.test.sova.local | личный кабинет |
| http://argocd.sova.local | ArgoCD |
| http://git.sova.local | Gitea |
## Разделы
- [Test-контур k3s](docs/test-contour-article.md) — полная статья: архитектура, bootstrap, CI, troubleshooting
- [Система тегов](docs/tags.md) — формат тегов и release-скрипт
- [ArgoCD приложения](docs/argocd-apps.md) — зачем `sova-root` и `data-test`, почему «нельзя войти»
Исходники: репозиторий `sova/docs` в Gitea, каталог `k3s-test/sova-docs/` локально.
-4
View File
@@ -1,4 +0,0 @@
* [Главная](/README.md)
* [Test-контур k3s](/docs/test-contour-article.md)
* [Система тегов CI/CD](/docs/tags.md)
* [ArgoCD: sova-root и data-test](/docs/argocd-apps.md)
-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Sova Test Contour Docs</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
</head>
<body>
<div id="app"></div>
<script>
window.$docsify = {
name: 'Sova Test Contour',
repo: '',
loadSidebar: true,
subMaxLevel: 3,
auto2top: true,
homepage: 'README.md',
search: 'auto',
};
</script>
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/docsify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
# Проверка локального контура
Эта страница описывает, как быстро убедиться, что локальные `backend`, `cabinet`, БД и тестовые данные работают корректно.
## Подготовка
Запустите локальный контур и соберите ассеты cabinet:
```bash
make local-up
make local-assets
```
Если нужно заново пересоздать тестовые данные:
```bash
make local-seed
```
## Автоматическая smoke-проверка
Основная команда проверки:
```bash
make local-smoke
```
Она проверяет:
- Swagger backend: `http://localhost:8081/docs`;
- Swagger cabinet: `http://localhost:8082/api/swagger`;
- backend авторизацию через JWT;
- backend справочники филиалов, отделений, врачей и прайса;
- cabinet страницу логина;
- cabinet авторизацию тестовым пользователем;
- cabinet главную страницу после логина;
- cabinet API баннера, прайса и врачей.
Успешный результат выглядит так:
```text
== Backend ==
OK backend swagger UI (200)
OK backend swagger JSON (200)
OK backend login
OK backend filial list
OK backend department list
OK backend specialist list
OK backend price list
== Cabinet ==
OK cabinet login page (200)
OK cabinet swagger UI (200)
OK cabinet swagger JSON (200)
OK cabinet login response
OK cabinet authenticated home (200)
OK cabinet banner API
OK cabinet price departments API
OK cabinet doctors API
Local smoke test passed.
```
## Тестовые пользователи
Backend API:
- login: `local.backend@example.test`
- password: `local-password`
- uid: `100001`
- region: `91`
- роли: `ROLE_USER`, `ROLE_ADMIN`
Cabinet:
- login: `local.cabinet@example.test`
- password: `local-password`
- uid: `200001`
- роли: `ROLE_USER`, `ROLE_ADMIN`
## Ручная проверка в браузере
1. Откройте `http://localhost:8081/docs`.
Должна открыться Swagger UI backend.
2. Откройте `http://localhost:8082/api/swagger`.
Должна открыться Swagger UI cabinet.
3. Откройте `http://localhost:8082/login`.
Должна открыться страница входа без ошибки `500`.
4. Войдите в cabinet:
- email: `local.cabinet@example.test`;
- password: `local-password`.
5. После входа должна открыться главная cabinet: `http://localhost:8082/`.
6. Откройте публичные API cabinet:
- `http://localhost:8082/api/banner/91`;
- `http://localhost:8082/api/pricelist/departments`;
- `http://localhost:8082/api/doctors/91`.
## Ручная проверка backend API
Получить JWT:
```bash
TOKEN=$(curl -sS \
-X POST http://localhost:8081/user/login \
-H 'Content-Type: application/json' \
--data '{"username":"local.backend@example.test","password":"local-password"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
```
Проверить текущего пользователя:
```bash
curl -sS http://localhost:8081/user/ \
-H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool
```
Ожидаемый смысл ответа: `successful: true`, `uid: 100001`, `regionId: 91`.
Проверить филиалы:
```bash
curl -sS 'http://localhost:8081/filial/list?regionId=91' \
| python3 -m json.tool
```
В ответе должен быть филиал `Сова Local`.
Проверить отделения:
```bash
curl -sS http://localhost:8081/department/list \
| python3 -m json.tool
```
В ответе должно быть отделение `Терапия`.
Проверить врачей:
```bash
curl -sS 'http://localhost:8081/specialist/list?regionId=91' \
| python3 -m json.tool
```
В ответе должен быть врач `Иванов Иван Иванович` с alias `ivanov-ivan`.
Проверить прайс:
```bash
curl -sS http://localhost:8081/pricelist/list \
| python3 -m json.tool
```
В ответе должна быть услуга `Прием терапевта` с локальной ценой.
## Ручная проверка cabinet API
Проверить баннер региона:
```bash
curl -sS http://localhost:8082/api/banner/91 \
| python3 -m json.tool
```
Ожидается JSON с `active: true`.
Проверить группы прайса:
```bash
curl -sS http://localhost:8082/api/pricelist/departments \
| python3 -m json.tool
```
В ответе должна быть группа `Консультации`.
Проверить список врачей:
```bash
curl -sS http://localhost:8082/api/doctors/91 \
| python3 -m json.tool
```
В ответе должен быть врач `Иванов Иван Иванович`, отзывы и прайс.
## Что делать при ошибках
Если `cabinet /login` отдает `500` с ошибкой про `public/build/entrypoints.json`, соберите ассеты:
```bash
make local-assets
```
Если API отдает пустые данные или ошибки типов, пересоздайте seed:
```bash
make local-seed
make local-smoke
```
Если контейнеры были пересозданы с нуля:
```bash
make local-up
make local-assets
make local-smoke
```