feat: migrate to VitePress from monorepo docs, add test-contour section

This commit is contained in:
sova-bootstrap
2026-05-28 12:29:31 +03:00
parent e90dfe1bd4
commit e3e438df68
76 changed files with 11998 additions and 60 deletions
+451
View File
@@ -0,0 +1,451 @@
---
title: Расписание врачей — синхронизация и отображение (Backend + Cabinet)
---
# Расписание врачей: синхронизация и отображение
> Полный разбор того, **как в проекте работает расписание врачей**: от Infoclinica MIS до карточки на сайте. Охватывает `apps/backend`, `apps/cabinet` и роль adminPanel.
>
> Связанные документы:
> - [2.2 Расписание и кеш (Backend)](../apps/backend-scenarios/schedule-cache.md)
> - [2.3 GetScheduleMessage](../apps/backend-scenarios/schedule-messenger.md)
> - [4.1 Синхронизация врачей (Idoctor)](../apps/backend-scenarios/sync-doctors-reviews.md)
> - [Backend: внешние сервисы](../infrastructure/backend-external-services.md) (MIS / Widget API)
> - [Cabinet: обзор](./cabinet.md)
---
## 0. TL;DR
В проекте **нет единого batch-sync всех слотов** всех врачей. Вместо этого работают **два независимых механизма**:
| Что | Как обновляется | Где хранится |
| --- | --- | --- |
| **Слоты времени** (`09:0009:30` и т.д.) | **По запросу** из Infoclinica MIS | Backend: таблица `schedule` (кэш 5 мин). Cabinet: **не хранит**, тянет live через `/api/interval` |
| **Ближайшая дата приёма** (`nearestDate`) | **Пакетно, раз в час** (cron) | Backend: `idoctor``location` / `specialist` через SQL-функции; Cabinet читает `location_view` |
**Cabinet и backend — параллельные потребители Infoclinica.** Cabinet **не вызывает** backend `GET /specialist/schedule`.
Источник правды по слотам — **Infoclinica MIS** (`MIS_URL` / `MIS` env, обычно `widget.sovamed.ru`).
---
## 1. Общая архитектура
```mermaid
flowchart TB
subgraph MIS [Infoclinica MIS]
API1["GET /api/reservation/intervals"]
API2["GET /api/reservation/schedule"]
API3["GET /specialists/doctors"]
API4["POST /api/reservation/anonymous-reserve"]
end
subgraph Backend [apps/backend]
EP1["GET /specialist/schedule"]
Handler["GetScheduleMessageHandler"]
Cache["ScheduleCacheService → schedule"]
Cron["upload:doctors 0/1"]
Idoctor[(idoctor)]
Location[(location)]
SP["update_specialist() / update_location()"]
end
subgraph Cabinet [apps/cabinet]
EP2["GET /api/interval"]
Stimulus["checkSchedule_controller.js"]
Views[(specialist_view / location_view)]
end
subgraph Admin [adminPanel + backend API]
AdminAPI["GET /idoctor/list\nCRUD specialist/location"]
end
EP1 --> Handler --> Cache
Handler --> API1
Cache --> API1
Cron --> API3 --> Idoctor --> SP --> Location
Stimulus --> EP2
EP2 --> API1
EP2 --> API2
AdminAPI --> Idoctor
Views -.->|"nearestDate для сортировки"| Location
```
---
## 2. Два типа «расписания»
### 2.1. Слоты (интервалы времени)
**Бизнес-смысл:** пациент видит конкретные свободные окна для записи.
**Поведение:** данные **не синхронизируются заранее**. При открытии карточки врача frontend запрашивает MIS (напрямую или через прокси backend/cabinet). Ответ содержит:
- `workDate` — дата приёма;
- `isFree` — есть ли свободные слоты в этот день;
- `intervals[]` — массив `{ time, schedident, isFree, rnum? }`.
**Кэш:** короткий (5 мин в backend, HTTP-кэш в cabinet), только чтобы снизить нагрузку на MIS.
### 2.2. Ближайшая дата (`nearestDate`)
**Бизнес-смысл:** «ближайшая запись — 28 мая», сортировка врачей «по времени приёма», фильтр по диапазону дат.
**Поведение:** **реальная синхронизация** — hourly cron тянет список врачей из MIS, сохраняет `nearestDate` в `idoctor`, затем SQL-функции обновляют `location` и view'ы cabinet.
**Не используется** для построения сетки слотов — слоты всегда live из MIS.
---
## 3. Backend (`apps/backend`)
### 3.1. API слотов: `GET /specialist/schedule`
**Точка входа:** `SpecialistController::specialistSchedule`
**Параметры** (`ScheduleDto`):
| Параметр | Тип | Описание |
| --- | --- | --- |
| `st`, `en` | integer | Границы периода в формате `Ymd` (например `20250525`) |
| `dcode` | integer | Код врача в Infoclinica |
| `filial` | integer | ID филиала (в query к MIS уходит как `filialId`) |
| `onlineMode` | boolean | `0` — очный приём, `1` — онлайн |
Query string для MIS и ключа кэша:
```php
// apps/backend/src/Dto/ScheduleDto.php
return http_build_query([
'st' => $this->st,
'en' => $this->en,
'dcode' => $this->dcode,
'onlineMode' => $this->onlineMode,
'filialId' => $this->filial,
]);
```
### 3.2. Цепочка вызовов
```
SpecialistController::specialistSchedule()
→ SpecialistService::getSchedule(ScheduleDto)
→ MessageBus dispatch GetScheduleMessage (sync transport)
→ GetScheduleMessageHandler::__invoke()
→ ScheduleCacheService::getCachedSchedule() // TTL 5 мин
→ InfoclinicaClientService::getSchedule() // при промахе
→ ScheduleCacheService::saveSchedule()
```
Подробнее: [schedule-messenger.md](../apps/backend-scenarios/schedule-messenger.md), [schedule-cache.md](../apps/backend-scenarios/schedule-cache.md).
### 3.3. Запрос в MIS
`InfoclinicaClientService` вызывает:
```
GET {MIS_URL}/api/reservation/intervals?st=...&en=...&dcode=...&onlineMode=...&filialId=...
```
Ответ нормализуется в структуру:
```json
{
"schedule": {
"<depnum>": {
"<Ymd>": {
"schedident": "...",
"dcode": "...",
"filial": 1,
"isFree": true,
"intervals": [
{ "time": "09:00-09:30", "isFree": true }
]
}
}
},
"nearestDate": { "<depnum>": 20250525 }
}
```
### 3.4. Таблица `schedule` — кэш слотов
Одна строка = **один интервал** (не «расписание врача целиком»).
| Поле | Назначение |
| --- | --- |
| `dcode`, `department`, `filial` | Привязка к врачу / отделению / филиалу |
| `schedident` | ID расписания в MIS (нужен для записи) |
| `workdate` | Дата приёма |
| `time` | Интервал `"HH:MM-HH:MM"` |
| `intervalIsFree` | Свободен ли слот |
| `onlineMode` | Очный / онлайн |
| `queryString` | Ключ кэша (полный query string запроса) |
| `createdAt` | Время записи в кэш |
| `priceInfo` | JSON, только для онлайн-расписания |
**TTL:** 5 минут. При `saveSchedule` старые строки с тем же `queryString` + `onlineMode` удаляются перед insert.
**Очистка:** команда `app:schedule:clear-cache` (по умолчанию старше 24 ч). В cron **не подключена**.
### 3.5. Batch-sync `nearestDate`: `upload:doctors`
**Команда:** `php bin/console upload:doctors [onlineMode]`
- `onlineMode=0` — очные врачи;
- `onlineMode=1` — онлайн.
**Алгоритм:**
1. Читает активные отделения из `department`.
2. Для каждого отделения вызывает MIS:
```
GET /specialists/doctors?departments={did}&onlineMode={0|1}&firstrow=&lastrow=
```
чанками по 300 записей.
3. Upsert в `idoctor` по ключу `dcode + department + onlineMode`.
4. Поля: `dcode`, `name`, `department`, `filial`, **`nearestDate`**, `onlineMode`.
Подробнее: [sync-doctors-reviews.md](../apps/backend-scenarios/sync-doctors-reviews.md).
### 3.6. Hourly cron
Файл `scripts/cron.hourly.sh`:
```bash
docker exec -t -u www-data php84 php bin/console upload:doctors 0
docker exec -t -u www-data php84 php bin/console upload:doctors 1
docker exec -t pgsql psql -U sova_api -d sova_api -c "SELECT public.update_specialist();"
docker exec -t pgsql psql -U sova_api -d sova_api -c "SELECT public.update_location();"
```
```mermaid
sequenceDiagram
participant Cron as cron.hourly.sh
participant Cmd as upload:doctors
participant MIS as Infoclinica MIS
participant DB as PostgreSQL
Cron->>Cmd: upload:doctors 0 (offline)
Cron->>Cmd: upload:doctors 1 (online)
loop каждое активное отделение
Cmd->>MIS: GET /specialists/doctors
MIS-->>Cmd: dcode, nearestDate, ...
Cmd->>DB: upsert idoctor
end
Cron->>DB: SELECT update_specialist()
Cron->>DB: SELECT update_location()
```
**Важно:** функции `update_specialist()` и `update_location()` вызываются из cron, но **их исходников нет в репозитории** — они живут в production PostgreSQL. Без них `nearestDate` в cabinet не обновится, хотя `idoctor` будет актуален.
### 3.7. Роль adminPanel
Admin panel **не синхронизирует слоты**. Она настраивает **метаданные**:
| Поле | Сущность | Назначение |
| --- | --- | --- |
| `displaySchedule` | `specialist` | Показывать ли виджет расписания |
| `scheduleText` | `specialist` | Текст под расписанием |
| `dcodes` | `specialist` | Коды врача в Infoclinica |
| `dcode`, `department`, `filial`, `onlineMode` | `location` | Привязка врача к MIS |
| `nearestDate` | `location` | Можно задать вручную или взять из `idoctor` |
Backend API:
- `GET /idoctor/list` — список синхронизированных врачей из Infoclinica (для модалки «Добавить расписание из Инфоклиники»);
- `POST/PUT /specialist/{id}/location/*` — CRUD локаций.
Без правильной `location` frontend не знает, какой `dcode` / филиал / отделение запрашивать у MIS.
### 3.8. Запись на приём (backend)
После выбора слота:
```
POST /reservation/anonymous-reserve
→ SpecialistService::createAnonymousReserve()
→ GetAnonymousReserveRequestMessageHandler
→ InfoclinicaClientService::anonymousReserve()
→ POST {MIS_URL}/api/reservation/anonymous-reserve
```
Поля из слота: `schedident`, `workDate`, `time`, `dcode`, `filial`, `rnum`.
Подробнее: [anonymous-reserve.md](../apps/backend-scenarios/anonymous-reserve.md).
---
## 4. Cabinet (`apps/cabinet`)
Cabinet — Symfony + Twig + Stimulus. **Не SPA.** Расписание на публичном сайте (`cabinet.sovamed.ru` / список врачей) реализовано здесь.
### 4.1. Принцип: live-запросы, не backend API
Cabinet **не вызывает** `apps/backend` `/specialist/schedule`. Свой прокси:
```
GET /api/interval?doctor=...&department=...&filial=...&startInterval=2025-05-25&endInterval=2025-06-01&onlineMode=0&update=true
```
**Контроллер:** `PublicAPIController::interval()`
Два параллельных запроса в MIS:
| MIS endpoint | Назначение |
| --- | --- |
| `GET /api/reservation/schedule` | Сетка рабочих дней (когда врач принимает) |
| `GET /api/reservation/intervals` | Конкретные слоты внутри дней |
Результат мержится: для каждого свободного дня из `schedule` подтягиваются `intervals` с тем же `schedident`.
HTTP-кэш: `CachingHttpClient` + store в `var/HttpClient` (короткий, не PostgreSQL).
### 4.2. Диапазон дат на странице врачей
`SpecialistController::index()`:
- если в фильтре задан `specialist_search[current_date]` — используется он;
- иначе **сегодня → +7 дней**.
Даты передаются в DOM как `data-st` / `data-en` на `.specialist-items` и используются Stimulus-контроллером.
### 4.3. Клиент: `checkSchedule_controller.js`
При загрузке карточки врача (если `specialist.infoclinica == true`):
1. Читает `dcode`, `filial`, `department`, `onlineMode` из DOM / селекта клиники.
2. Вызывает `GET /api/interval`.
3. Рендерит до **6 свободных слотов** на карточке.
4. Кнопка «Все даты» открывает модалку (`specialistView_controller.js`) с навигацией по неделям — тот же `/api/interval`.
**Формат значения селекта клиники:** `dcode:filial:department:onlineMode:infoclinica`
**Twig:** `templates/specialist/_item.html.twig` — монтирует `data-controller="checkSchedule"`.
### 4.4. Врачи без Infoclinica
Если `specialist.infoclinica == false` (Bitrix `HIDE_TIMETABLE` или нет привязки к MIS):
- контроллер `checkSchedule` **не монтируется**;
- вместо слотов — кнопка «Записаться» → заявка в Bitrix CRM (`uslugi_controller.js`).
### 4.5. `nearestDate` в cabinet
Cabinet читает `nearestDate` из read-only view `location_view` (`LocationView` entity):
- сортировка «по времени приёма»;
- фильтр по диапазону дат в поиске;
- **не** для построения сетки слотов.
View обновляется SQL-функциями backend cron (`update_location()`).
### 4.6. Запись на приём (cabinet)
1. Клик по слоту → `record.js` → форма записи.
2. **Авторизованный пользователь:** `webSDK.scheduleRecReserve()` напрямую в Infoclinica SDK (`widget.sovamed.ru`).
3. **Анонимный:** `POST /api/anonymous-reserve` → MIS `/api/reservation/anonymous-reserve`.
### 4.7. Legacy: старые CLI-команды cabinet
В `apps/cabinet/src/Command/` есть **устаревший** путь полной синхронизации в локальные таблицы:
```bash
php bin/console app:Infoclinica schedules # → таблица Schedule
php bin/console app:Infoclinica intervals # → таблица Interval
php bin/console upload:doctorsInfoclinica # → таблица Idoctor
```
**Статус: legacy.** Миграция `Version20250907100913` удалила таблицы `specialist`, `location`, `idoctor` из cabinet. Runtime UI перешёл на DB views (`specialist_view`, `location_view`), batch-sync — в backend (`upload:doctors`).
Текущий cabinet **не читает** локальные `Schedule` / `Interval` для UI.
---
## 5. Сравнение Backend vs Cabinet
| Аспект | Backend | Cabinet |
| --- | --- | --- |
| Endpoint слотов | `GET /specialist/schedule` | `GET /api/interval` |
| MIS endpoints | только `/intervals` | `/schedule` + `/intervals` |
| Кэш слотов | PostgreSQL `schedule`, 5 мин | HTTP cache `var/HttpClient` |
| Batch sync `nearestDate` | `upload:doctors` + SQL functions | Читает `location_view` |
| Frontend | API для внешних клиентов | Stimulus JS на Twig-страницах |
| Запись | `/reservation/anonymous-reserve` | `/api/anonymous-reserve` + WrSDK |
---
## 6. Ключевые файлы
### Backend
| Файл | Роль |
| --- | --- |
| `src/Controller/SpecialistController.php` | `GET /specialist/schedule` |
| `src/Service/Specialist/SpecialistService.php` | dispatch messenger |
| `src/MessageHandler/GetScheduleMessageHandler.php` | cache + MIS |
| `src/Service/ScheduleCache/ScheduleCacheService.php` | TTL 5 мин |
| `src/Service/Client/InfoclinicaClientService.php` | HTTP к MIS |
| `src/Command/UploadDoctorsCommand.php` | hourly sync `nearestDate` |
| `src/Entity/Schedule.php`, `Idoctor.php`, `Location.php` | модели |
| `scripts/cron.hourly.sh` | orchestration |
### Cabinet
| Файл | Роль |
| --- | --- |
| `src/Controller/PublicAPIController.php` | `GET /api/interval` |
| `assets/controllers/checkSchedule_controller.js` | слоты на карточке |
| `assets/controllers/specialistView_controller.js` | модалка «Все даты» |
| `templates/specialist/_item.html.twig` | разметка карточки |
| `src/Controller/SpecialistController.php` | диапазон дат st/en |
| `src/Entity/LocationView.php` | `nearestDate` для фильтров |
---
## 7. Отладка
```bash
# Batch sync nearestDate
docker exec -u www-data php84 php bin/console upload:doctors 0 -v
docker exec pgsql psql -U sova_api -d sova_api -c "SELECT dcode, nearest_date, online_mode FROM idoctor LIMIT 10;"
# Статистика кэша слотов backend
docker exec -u www-data php84 php bin/console app:schedule:clear-cache --stats
# Прямой запрос backend API
curl "http://localhost:8081/specialist/schedule?st=20250525&en=20250601&dcode=XXX&filial=1&onlineMode=0"
# Cabinet proxy
curl "http://localhost:8082/api/interval?doctor=XXX&department=YYY&filial=1&startInterval=2025-05-25&endInterval=2025-06-01&onlineMode=0"
```
---
## 8. Известные нюансы
1. **`GetScheduleMessageHandler`** вызывает `getSchedule($queryString, $isOnlineMode)` с двумя аргументами, а `InfoclinicaClientService::getSchedule()` в репозитории принимает один — потенциальная runtime-ошибка; проверить на deploy-окружении.
2. **`update_specialist()` / `update_location()`** критичны для hourly sync, но **не в git** — только в production PostgreSQL.
3. **`app:schedule:clear-cache`** не в cron — старые строки в `schedule` копятся, но не отдаются (TTL проверяется при чтении).
4. **Cabinet и backend не связаны по слотам** — изменения в backend cache не влияют на cabinet.
5. **Legacy-команды cabinet** (`app:Infoclinica schedules/intervals`) ссылаются на удалённые таблицы — не использовать для текущего UI.
---
## 9. Ментальная модель
| Задача | Механизм | Хранение | Триггер |
| --- | --- | --- | --- |
| Показать слоты на сайте | Live-запрос в MIS | Не хранится (HTTP cache) | Открытие карточки врача |
| API слотов для внешних клиентов | Live + DB cache 5 мин | `schedule` | `GET /specialist/schedule` |
| Сортировка «по ближайшей записи» | Batch sync | `idoctor` → `location` → views | Hourly cron |
| Привязка врача к MIS | Admin + sync | `location`, `specialist.dcodes` | Admin panel + cron |
| Запись на приём | Push в MIS | `record` (локально) | POST anonymous-reserve |