452 lines
20 KiB
Markdown
452 lines
20 KiB
Markdown
---
|
||
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 |
|