--- 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": { "": { "": { "schedident": "...", "dcode": "...", "filial": 1, "isFree": true, "intervals": [ { "time": "09:00-09:30", "isFree": true } ] } } }, "nearestDate": { "": 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 |