↩️ Назад

Категории

Организация онлайн-записи на сайте клиники через 1с

30.09.2026 | из категории: Web-разработка

Онлайн-запись на сайт клиники из 1С: как устроено и что нас

Задача звучала буднично: «пусть пациент сам выберет врача и время, данные пусть берутся из 1С, запись не сразу в 1с а через операторов кол-центра. Пользователь выбирает свободное окно из расписания 1с и отправляет личные данны. Оператору приходит запрос, он перезванивает клиенту и записывает уже в реальную базу 1с вручную».

Разбор проекта · PHP 8 · MySQL · cron · REST-подобный HTTP-сервис 1С

1. Что просили

Клиника уже показывала расписание врачей на сайте: скрипт по звонку в 1С вытягивал приёмы конкретного пациента и рисовал список. Рядом (по времени — задолго до) жил 1С‑сервис, отдающий свободные окна врачей. Из этого нужно было сделать публичную онлайн-запись.

Пожелания заказчика в итоге выглядели так:

  • пациент вводит ФИО, телефон и, по желанию, почту;
  • выбирает специальность врача, затем врача — оба шага необязательны;
  • есть кнопка «не знаю, к кому записаться»: тогда время не выбирается вовсе, оператор сам подберёт специалиста и позвонит;
  • ниже — календарь и свободное время; время показывать понятными интервалами «от–до», а не одним окном на полдня;
  • центры (филиалы) пациенту не показывать — это внутренняя кухня, оператор разберётся;
  • никакой записи в 1С: сайт только читает 1С и складывает заявки в свою базу, всё остальное делает оператор звонком.
Ключевое ограничение Появляется третья сторона — база, которая живёт своей жизнью между 1С и пациентом. Именно она снимает нагрузку с 1С, даёт «заморозку» уже занятого времени и позволяет показать заявку оператору, даже если в 1С ещё ничего нет.

2. Архитектура: два потока данных

Проект получился из двух независимых линий, которые встречаются в одной базе.

1С (HTTP-сервис свободных окон) │ │ 1 запрос в день, JSON 16–90 КБ ▼ ┌───────────────────────────────┐ │ cron/import_slots.php │ раз в час, по расписанию хостинга │ • дедупликация по хешу дня │ │ • нарезка окон на слоты │ └───────────────┬───────────────┘ │ пишет ▼ ┌───────────────────┐ ┌────────────────────────────┐ │ MySQL │◀───────│ api/book.php (заявка) │ │ online_slots │───────▶│ помечает слот 'held' │ │ online_staff │ └────────────────────────────┘ │ online_bookings │ └─────────┬─────────┘ │ читает ┌─────────┴─────────┐ ┌────────────────────────────┐ │ api/days.php │───────▶│ index.php — интерфейс │ │ api/slots.php │ │ календарь + врачи + время │ └───────────────────┘ └────────────────────────────┘

Есть и второй режим — «живой»: если пациент хочет данные прямо сейчас, сервер делает запрос в 1С в момент открытия дня, а результат кладёт в кэш на 10 минут и ограничивает частоту обращений с одного адреса. Нужен он редко, но иногда полезен: например, когда час импорта ещё не прошёл, а пациента интересует только что освободившееся время.

3. Как выглядит сервис 1С и что в нём неудобно

Адрес предельно простой — даты в пути, минимальная длительность в параметре:

GET http://192.0.2.10/poliweb/hs/doctors/free-slots/2026-10-01/2026-10-01
        ?минимальноеВремя=40

Ответ — дерево «врачи → интервалы» с русскими ключами:

{
  "ДатаНачала": "2026-10-01",
  "ДатаОкончания": "2026-10-01",
  "МинимальнаяДлительностьМинут": 40,
  "Врачи": [
    {
      "Врач": "Иванова Наталья Петровна",
      "Должность": "врач по лечебной физкультуре",
      "Подразделение": "Филиал А",
      "СвободныеИнтервалы": [
        {
          "Подразделение": "Филиал А",
          "РабочееМесто": "Иванова Наталья Петровна",
          "НачалоПоГрафику": "01.10.2026 9:00:00",
          "ОкончаниеПоГрафику": "01.10.2026 14:00:00",
          "Начало": "01.10.2026 12:00:00",
          "Окончание": "01.10.2026 14:00:00",
          "МинутыСвободные": 120
        }
      ]
    }
  ]
}

Что выяснилось опытным путём (каждое — реальная проверка, не предположение):

ПоведениеЧто это значит для нас
Параметр минимальноеВремя обязателен, ноль отвергается Всегда передаём число больше нуля; при ошибке сервис отдаёт {"Ошибка": "..."} и HTTP 400
POST на этот адрес отвечает 405 Сервис только на чтение. Записать приём средствами сайта нельзя — и не надо, это делает оператор
Фильтр по ФИО и подразделению понимает только «короткие» имена (Иванова, Филиал), а полные строки отдаёт пусто Не полагаемся на серверный фильтр: забираем день целиком и отсеиваем сами
Параметр должность игнорируется вовсе Фильтр по специальности — на нашей стороне, иначе он «не работает» загадочным образом
В начале ответа — BOM, даты в формате 01.10.2026 9:00:00 Снимаем BOM, разбираем даты отдельной функцией, а не strtotime «на удачу»
Свободное окно может быть огромным: «12:00–14:00», «15:00–20:00» Именно это и вынудило резать время на часы (см. раздел 5)
Почему фильтруем сами Соблазн переложить фильтрацию на 1С велик: меньше данных, меньше кода. Но когда параметр то работает, то нет (а «короткое» и «полное» имя дают разный результат), получаешь баг, который невозможно объяснить пользователю. Проще один раз забрать день целиком, чем потом разбираться, почему у врача «пропали» окна.

4. Конвейер: от JSON до базы

Импорт живёт в одном месте — функции onz_import_run(), которую вызывают и cron, и кнопка в админке. Дубировать логику в двух файлах — верный способ получить расхождение «в кроне работает, в панели нет».

for ($day = today .. today + 7) {
    $r = onz_api_free_slots($day, $min);        // 1 запрос к 1С на день
    if (!$r['ok']) { логируем и идём дальше; }  // падение одного дня не рушит остальные

    $hash = md5(json_encode($r['data']));
    if ($hash === прошлый_хеш && нет расхождений в базе) { пропускаем; }

    onz_store_day($db, $r['data'], $min, $marker, $day);   // врачи + слоты
    запоминаем хеш дня;
}
onz_expire($db, $записанные_дни, $marker);      // помечаем исчезнувшее и прошедшее

Ключей в базе два:

  • doc_key — sha1 от «ФИО + подразделение»: стабильный идентификатор врача, который не зависит от порядка записей в 1С;
  • slot_uid — sha1 от «врач + начало слота»: по нему связываются слот и заявка.

Сама запись — идемпотентный upsert. Один и тот же код работает и на MySQL, и на SQLite (последнее — чтобы прогонять всё локально без сервера):

function onz_upsert(PDO $db, string $table, array $row, array $updates): void {
    $cols = array_keys($row);
    $sql  = "INSERT INTO $table (" . implode(', ', $cols) . ") VALUES ("
          . implode(', ', array_map(fn($c) => ':' . $c, $cols)) . ")";
    $sql .= onz_is_sqlite($db)
        ? ' ON CONFLICT DO UPDATE SET ' . implode(', ', $updates)
        : ' ON DUPLICATE KEY UPDATE '   . implode(', ', $updates);
    $db->prepare($sql)->execute($row);
}

5. Почему окна пришлось резать на часы

Пациенту нельзя показать «свободно с 15:00 до 20:00». Это не выбор времени, это загадка: приём длится час, а окно пять часов — что именно он выбирает? Усугубляют дело окна, начинающиеся не по-человечески: 1С легко отдаёт «14:40–20:00».

Решение — резать окно на слоты уже при импорте, а не в браузере. Тогда и в интерфейсе, и в админке, и в письме оператору одно и то же время:

function onz_split_interval(string $startDt, string $endDt,
                            int $stepMinutes = 60, string $place = ''): array {
    $step = max(10, $stepMinutes) * 60;
    $t0 = strtotime($startDt);
    $t1 = strtotime($endDt);

    // всё, что короче слота, не показываем вовсе
    if (!$t0 || !$t1 || ($t1 - $t0) < $step) { return []; }

    $out = [];
    $dayStart = strtotime(date('Y-m-d 00:00:00', $t0));
    // первую границу привязываем к ровному часу, а не к началу окна
    $cur = $dayStart + (int)ceil(($t0 - $dayStart) / $step) * $step;

    while ($cur + $step <= $t1) {
        $out[] = ['start' => date('H:i', $cur),
                  'end'   => date('H:i', $cur + $step),
                  'place' => $place];
        $cur += $step;
    }
    return $out;
}

Правила получились такие (проверены на живых данных):

Что отдала 1СЧто показывает сайт
15:00 – 20:0015:00–16:00, 16:00–17:00, 17:00–18:00, 18:00–19:00, 19:00–20:00
14:40 – 20:0015:00–16:00 … 19:00–20:00 (огрызок 14:40–15:00 отброшен)
09:30 – 14:0010:00–11:00, 11:00–12:00, 12:00–13:00, 13:00–14:00
15:00 – 16:3015:00–16:00 (полчаса в хвосте отброшены)
09:00 – 09:40не показываем — короче часа
Почему «короче часа» выброшено без сожалений Приём занимает час. Показать слот на 20 минут — значит пообещать пациенту то, чего клиника не сделает. Минуты не пропадают: оператор всё равно звонит и подтверждает время, у него эти окна остаются. Длину слота можно поменять одной настройкой (online_slot_minutes), если в клинике приём станет короче.

6. Интерфейс: специальность, врач, время и «не знаю, к кому»

Форма собирается из четырёх шагов, и это не украшательство: на каждом шаге количество вариантов сокращается, поэтому пациент не тонет в списке из 24 врачей и 130 слотов.

  1. Контакты. ФИО, телефон с маской +7 (999) 123-45-67, почта — необязательно.
  2. Специальность. Выпадающий список из 1С («логопед», «нейропсихолог», «врач-невролог»…).
  3. Врач. Список уже сужен по специальности; можно не выбирать.
  4. День и время. Календарь показывает ровно период записи — «сегодня + 7 дней», под каждым днём число свободных окон. Выбрал день — ниже появились слоты и список тех, кто в этот день принимает.

Отдельная ветка — кнопка «не знаю, к кому записаться». Она скрывает всё, что ниже специальности, и оставляет только контакты: пациент отправляет ФИО, телефон, почту и, если выбрал, специальность. Сервер принимает такую заявку, не занимая время в базе:

// api/book.php: две принципиально разные заявки
$withSlot = ($docKey !== '' && $day !== '' && $start !== '');

if (!$withSlot) {
    // «не знаю, к кому»: времени нет, врач не выбран, оператор подберёт вручную
    INSERT INTO online_bookings (slot_uid, doc_key, day, start_dt, ...)
    VALUES ('', NULL, NULL, NULL, ...);
} else {
    // заявка с конкретным временем: проверяем, что окно ещё свободно, и занимаем его
    ... BEGIN; INSERT; UPDATE online_slots SET status='held'; COMMIT;
}

Заявки без времени в админке подсвечены отдельным цветом и подписаны «без времени — подобрать». Мелочь, но именно она спасает от ситуации «заявка есть, а никто её не видит».

7. Заявка: откуда берётся «время занято»

Между пациентом, который смотрит на слот, и пациентом, который его занимает, проходит время. Поэтому заявка — не просто INSERT:

$db->beginTransaction();

// 1) ещё раз проверяем, что окно наше и свободно
$slot = SELECT ... WHERE doc_key = ? AND start_dt = ?;
if (!$slot)                     → «окно не найдено, обновите список» (409)
if ($slot['status'] !== 'free') → «это время уже занято»         (409)

// 2) пишем заявку
INSERT INTO online_bookings (...);

// 3) замораживаем окно — второй человек его уже не увидит
UPDATE online_slots SET status='held' WHERE doc_key=? AND start_dt=? AND status='free';

$db->commit();

Освобождение — зеркальная операция: оператор жмёт «отмена», статус заявки меняется на canceled, а слот возвращается в free. Всё в одной транзакции, чтобы не поймать состояние «заявка отменена, а время навсегда занято».

Почему 'held', а не 'booked' Статус held честно говорит: это ещё не запись, это заявка, которую человек должен подтвердить звонком. Так и в тексте для пациента: «онлайн-запись не является подтверждением, дождитесь звонка оператора».

8. Часовое обновление и дедупликация

Cron в панели хостинга запускает один и тот же скрипт и по расписанию, и вручную:

0 * * * *  /usr/bin/php /path/to/cron/import_slots.php --quiet
$ php cron/import_slots.php --days=3 --dry        # посмотреть, ничего не записывая
$ php cron/import_slots.php --day=2026-10-05      # один день
$ php cron/import_slots.php --slot=30             # длина слота вместо часа
$ php cron/import_slots.php --json                # одна строка JSON — удобно парсить

Экономия строится на хеше: если ответ 1С по этому дню байт в байт совпал с прошлым прогоном, писать нечего. Но есть исключение, и оно важнее экономии:

// Пропускаем день только если ответ не изменился И в базе нет расхождений.
$needFix = !$dry && onz_day_needs_rewrite($db, $day);
if ($prev === $hash && !$force && !$needFix) { continue; }

Если в базе есть слоты со статусом gone, день перезаписывается принудительно. Иначе расхождение, появившееся однажды (ручная правка, частично упавшая запись, отменённая заявка), останется в базе навсегда — а база будет уверенно утверждать, что всё в порядке.

9. Грабли, на которые мы наступили

Ради этого раздела всё и писалось. Каждая строка — потраченное время, которое можно не терять.

1С

  • POST → 405. Сервис только на чтение. Планировать «записать приём из формы», не уточнив это, — верный путь к переделке.
  • Фильтры понимают короткие имена. фио=Иванова находит, полное ФИО — нет. То же с подразделением. Проще фильтровать локально.
  • Должность игнорируется. Тихий баг: параметр передаётся, а результат — все врачи. Если бы не сверили со списком «всех», решили бы, что фильтр работает.
  • Горизонт ограничен. Данные есть на пару недель вперёд, дальше пусто. Календарь не должен предлагать месяцы, на которые записи в принципе нет.
  • Один день — один запрос. Не диапазон: так ошибка одного дня не утаскивает весь прогон, а размер ответа (16–90 КБ) и время (50–500 мс) предсказуемы.

Данные и статусы

  • Импорт не должен воскрешать занятое. Обновление статуса касается только free и gone: иначе часовой прогон вернёт в свободные время, которое уже занято заявкой.
  • Пустой ответ ≠ «всё занято». Помечать окна исчезнувшими можно только после непустого успешного прогона — иначе одна сетевая ошибка очистит расписание.
  • Пропущенные дни нельзя чистить. Самый дорогой баг проекта: день, пропущенный по совпадению хеша, не обновляет метку «видели в последний раз», и уборка мусора отправляет его живые окна в архив. Чистить можно только те дни, которые в этом прогоне действительно записали.

Инструменты, которые врут

  • Консоль Windows и кириллица. curl --data-urlencode в git-bash отправляет русские параметры в cp1251. Сервер их не понимает и отвечает пустотой — выглядит как «фильтр не работает», а на самом деле сломана отправка. Проверять такие вещи из браузера или из Python (UTF-8).
  • Строка подключения против реальности. Проверка «видит ли наш сервер чужой сервис» должна идти с того самого сервера. У нас запрос к внутреннему адресу с рабочей машины отвечал, а с хостинга уходил в таймаут — потому что наружу открыт другой адрес того же сервиса. Универсальный приём: маленький PHP-скрипт, который печатает результат по каждому адресу, и запуск его с боевого сервера.
  • Автотесты ловят то, что глазами не видно. Форма отправляла null вместо пустой строки — URLSearchParams превращает его в текст «null», а сервер честно ругался на некорректную дату. Кнопка была бы просто мёртвой.

10. Цифры и нагрузка

ПоказательЗначение
Горизонт записи8 календарных дней («сегодня + 7»)
Запросов к 1С за час8 (по одному на день)
Размер ответа по дню16–90 КБ
Время ответа 1С50–500 мс на день; 14 дней диапазоном — ~2,8 с
Полный прогон импорта~2 с на 8 дней, до 130 свободных слотов
Запись в базу при неизменных данныхнулевая — сравниваем хеш дня
Человеческая меркаэто меньше, чем один администратор, открывший расписание

11. Структура кода

Проект умышленно плоский: ни фреймворка, ни композера — его будет сопровождать не тот, кто писал. Все файлы к статье лежат рядом в папке code/.

config.sample.php — образец настроек: база, адрес 1С, горизонт, длина слота, ключ запуска, адреса для писем.
lib/onz.php — вся логика: конфиг, подключение, схема таблиц, разбор дат 1С, запрос, нарезка окон, импорт, почта. Один источник правды для cron и админки.
cron/import_slots.php — точка входа для расписания: аргументы, отчёт, код возврата. Весь импорт — вызов функции из lib.
api/days.php — дни для календаря, список специальностей и врачей.
api/slots.php — свободное время: из базы (обычно) или живым запросом в 1С (по кнопке), с кэшем и ограничением частоты.
api/book.php — приём заявки, проверка свободного окна, «заморозка» слота, письмо оператору.
api/admin.php — панель: заявки со статусами, настройки, кнопка «импорт сейчас».
schema.mysql.sql — таблицы: online_staff, online_slots, online_bookings, online_events, online_cache, online_limits, online_settings.

Имена таблиц с префиксом online_ — чтобы жить в той же базе, что и старый проект расписания, и ничего не задеть. Схема к тому же умеет догонять саму себя: если таблица существует с прошлой версии, недостающие колонки добавляются на месте.

12. Что дальше и что осталось за кадром

Проект работает, но честный разбор без списка «что не сделано» — не разбор.

  • Передачи записи в 1С нет. У сервиса нет метода создания приёма, а придумать его с нашей стороны нечем. Пока заявка копится в базе и оператор заносит время руками. В таблице уже есть поле под идентификатор внешней системы.
  • Защита от ботов простая. Ограничение частоты по адресу и обязательные контакты — всё. Для публичной формы в медицине нужна и капча, и явное согласие на обработку персональных данных. Согласие сделано, капча — нет.
  • Писем по умолчанию нет. Письмо оператору готово и включается адресом в настройках; по умолчанию выключено, чтобы заявки просто копились и ничего не улетало наружу.
  • Сверка с 1С односторонняя. Если время заняли прямо в 1С, наш слот со временем получит статус «исчез» — но не в тот же миг, а при следующем часовом прогоне.
Что в итоге получилось Восемь дней расписания, 130 свободных слотов, один скрипт по расписанию, база в несколько десятков килобайт и интерфейс, в котором пациент за четыре шага доходит до конкретного часа. И ни одной записи в 1С — ровно как договаривались.

Если делаете похожее: главный совет не про код, а про проверки. Сначала выясните точно, что чужой сервис умеет (метод записи, фильтры, горизонт), потом рисуйте интерфейс. И проверяйте доступ к чужому сервису с того сервера, который будет туда ходить — иначе неделя уйдёт на поиск бага, которого нет.

Файлы потом выложу, если честно надоело выкладывать, кому надо пишите письма )

Теги: #1с #backend #cron #http #JSON #mysql #php #Интеграции #Медицина



Категории:

Категории

← Назад к списку

Посетителей сегодня: 0
о блоге | карта блога | 📡 Подписаться на RSS

© Digital Specialist | Не являемся сотрудниками Google, Яндекса и NASA