- Что просили
- Архитектура: два потока данных
- Как выглядит сервис 1С и что в нём неудобно
- Конвейер: от JSON до базы
- Почему окна пришлось резать на часы
- Интерфейс: специальность, врач, время и «не знаю, к кому»
- Заявка: откуда берётся «время занято»
- Часовое обновление и дедупликация
- Грабли, на которые мы наступили
- Цифры и нагрузка
- Структура кода
- Что дальше и что осталось за кадром
1. Что просили
Клиника уже показывала расписание врачей на сайте: скрипт по звонку в 1С вытягивал приёмы конкретного пациента и рисовал список. Рядом (по времени — задолго до) жил 1С‑сервис, отдающий свободные окна врачей. Из этого нужно было сделать публичную онлайн-запись.
Пожелания заказчика в итоге выглядели так:
- пациент вводит ФИО, телефон и, по желанию, почту;
- выбирает специальность врача, затем врача — оба шага необязательны;
- есть кнопка «не знаю, к кому записаться»: тогда время не выбирается вовсе, оператор сам подберёт специалиста и позвонит;
- ниже — календарь и свободное время; время показывать понятными интервалами «от–до», а не одним окном на полдня;
- центры (филиалы) пациенту не показывать — это внутренняя кухня, оператор разберётся;
- никакой записи в 1С: сайт только читает 1С и складывает заявки в свою базу, всё остальное делает оператор звонком.
2. Архитектура: два потока данных
Проект получился из двух независимых линий, которые встречаются в одной базе.
Есть и второй режим — «живой»: если пациент хочет данные прямо сейчас, сервер делает запрос в 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) |
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:00 | 15:00–16:00, 16:00–17:00, 17:00–18:00, 18:00–19:00, 19:00–20:00 |
| 14:40 – 20:00 | 15:00–16:00 … 19:00–20:00 (огрызок 14:40–15:00 отброшен) |
| 09:30 – 14:00 | 10:00–11:00, 11:00–12:00, 12:00–13:00, 13:00–14:00 |
| 15:00 – 16:30 | 15:00–16:00 (полчаса в хвосте отброшены) |
| 09:00 – 09:40 | не показываем — короче часа |
online_slot_minutes), если в клинике приём станет короче.
6. Интерфейс: специальность, врач, время и «не знаю, к кому»
Форма собирается из четырёх шагов, и это не украшательство: на каждом шаге количество вариантов сокращается, поэтому пациент не тонет в списке из 24 врачей и 130 слотов.
- Контакты. ФИО, телефон с маской
+7 (999) 123-45-67, почта — необязательно. - Специальность. Выпадающий список из 1С («логопед», «нейропсихолог», «врач-невролог»…).
- Врач. Список уже сужен по специальности; можно не выбирать.
- День и время. Календарь показывает ровно период записи — «сегодня + 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 честно говорит: это ещё не запись, это заявка, которую человек
должен подтвердить звонком. Так и в тексте для пациента: «онлайн-запись не является
подтверждением, дождитесь звонка оператора».
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С, наш слот со временем получит статус «исчез» — но не в тот же миг, а при следующем часовом прогоне.
Если делаете похожее: главный совет не про код, а про проверки. Сначала выясните точно, что чужой сервис умеет (метод записи, фильтры, горизонт), потом рисуйте интерфейс. И проверяйте доступ к чужому сервису с того сервера, который будет туда ходить — иначе неделя уйдёт на поиск бага, которого нет.
Файлы потом выложу, если честно надоело выкладывать, кому надо пишите письма )