Files
docs/apps/doctor-schedule-sync.md

452 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 |