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

20 KiB
Raw Permalink Blame History

title
title
Расписание врачей — синхронизация и отображение (Backend + Cabinet)

Расписание врачей: синхронизация и отображение

Полный разбор того, как в проекте работает расписание врачей: от Infoclinica MIS до карточки на сайте. Охватывает apps/backend, apps/cabinet и роль adminPanel.

Связанные документы:


0. TL;DR

В проекте нет единого batch-sync всех слотов всех врачей. Вместо этого работают два независимых механизма:

Что Как обновляется Где хранится
Слоты времени (09:0009:30 и т.д.) По запросу из Infoclinica MIS Backend: таблица schedule (кэш 5 мин). Cabinet: не хранит, тянет live через /api/interval
Ближайшая дата приёма (nearestDate) Пакетно, раз в час (cron) Backend: idoctorlocation / specialist через SQL-функции; Cabinet читает location_view

Cabinet и backend — параллельные потребители Infoclinica. Cabinet не вызывает backend GET /specialist/schedule.

Источник правды по слотам — Infoclinica MIS (MIS_URL / MIS env, обычно widget.sovamed.ru).


1. Общая архитектура

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 и ключа кэша:

// 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, schedule-cache.md.

3.3. Запрос в MIS

InfoclinicaClientService вызывает:

GET {MIS_URL}/api/reservation/intervals?st=...&en=...&dcode=...&onlineMode=...&filialId=...

Ответ нормализуется в структуру:

{
  "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.

3.6. Hourly cron

Файл scripts/cron.hourly.sh:

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();"
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.


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/ есть устаревший путь полной синхронизации в локальные таблицы:

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. Отладка

# 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 idoctorlocation → views Hourly cron
Привязка врача к MIS Admin + sync location, specialist.dcodes Admin panel + cron
Запись на приём Push в MIS record (локально) POST anonymous-reserve