---
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 |
