feat: migrate to VitePress from monorepo docs, add test-contour section
@@ -17,11 +17,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Validate site files
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Build VitePress site
|
||||||
run: |
|
run: |
|
||||||
test -f site/index.html
|
npm ci
|
||||||
test -f site/README.md
|
npm run build
|
||||||
test -f site/docs/test-contour-article.md
|
test -f .vitepress/dist/index.html
|
||||||
|
test -f infrastructure/test-contour/test-contour-article.md
|
||||||
|
|
||||||
parse-tag:
|
parse-tag:
|
||||||
if: startsWith(github.ref, 'refs/tags/docs-v')
|
if: startsWith(github.ref, 'refs/tags/docs-v')
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.vitepress/cache/
|
||||||
|
.vitepress/dist/
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { defineConfig } from 'vitepress'
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
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: []
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
FROM nginx:1.27-alpine
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -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` | Избранное |
|
||||||
@@ -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` |
|
||||||
|
| Когда выбирать | Много однотипных сущностей | Один сложный домен с датами и картинкой |
|
||||||
@@ -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` не трогать |
|
||||||
@@ -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).
|
||||||
@@ -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®ionId=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.
|
||||||
@@ -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`).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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) |
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
@@ -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 стоит рассматривать как исторический или альтернативный сценарий.
|
||||||
@@ -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:00–09: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 |
|
||||||
@@ -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` | карточка врача |
|
||||||
@@ -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)
|
||||||
@@ -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 или синхронизированных таблиц.
|
||||||
@@ -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 и контактов владельцев интеграций;
|
||||||
|
- восстановление локальной БД из дампа.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 108 KiB |
@@ -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 |
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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-схемы и основные сущности.
|
||||||
@@ -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.*
|
||||||
@@ -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 глобально на машине.
|
||||||
@@ -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-backend/` | Копия Symfony API + Dockerfile + workflow Gitea |
|
||||||
| `sova-adminpanel/` | Копия React admin + runtime `env.js` |
|
| `sova-adminpanel/` | Копия React admin + runtime `env.js` |
|
||||||
| `sova-cabinet/` | Копия Symfony ЛК + Dockerfile + workflow Gitea |
|
| `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-deploy/` | Helm charts, ArgoCD manifests, SQL для test-БД |
|
||||||
| `sova-mocks/` | WireMock (MIS, Calltouch, Captcha) + Mailpit |
|
| `sova-mocks/` | WireMock (MIS, Calltouch, Captcha) + Mailpit |
|
||||||
| `sova-platform/` | Terraform-модуль установки k3s на VM |
|
| `sova-platform/` | Terraform-модуль установки k3s на VM |
|
||||||
@@ -5,10 +5,10 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ $uri/index.html /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.(md|html|css|js|json)$ {
|
location ~* \.(html|css|js|json|svg|woff2?|png|jpg|jpeg|gif|ico)$ {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "public, max-age=3600";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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/` локально.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||