fix(booking): 30-min slot intervals and 1C MCP auth synchronization

This commit is contained in:
Dana Medical Developer 2026-10-14 13:53:57 +00:00
parent 4058a1636d
commit b892fd6c4a
16 changed files with 770 additions and 18 deletions

6
bridge/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM python:3.11-alpine
WORKDIR /app
RUN pip install --no-cache-dir requests psycopg2-binary
COPY bridge_1c.py /app/bridge_1c.py
EXPOSE 8088
CMD ["python", "/app/bridge_1c.py"]

635
bridge/bridge_1c.py Normal file
View File

@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""
1C:Enterprise HTTP Bridge for Dana Medical Website.
Translates REST API calls from Go backend to 1C MCP Toolkit /api/execute_code.
Runs in Docker container on 195.95.133.47 and talks to 1C at 10.240.70.70:6003.
"""
import sys
import json
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timezone
import requests
import psycopg2
MCP_URL = "http://10.240.70.70:6003/api/execute_code"
PG_HOST = "db"
PG_PORT = 5432
PG_USER = "dana"
PG_DB = "dana"
PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0"
def get_linked_doctor_ids():
try:
conn = psycopg2.connect(
host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=3
)
cur = conn.cursor()
cur.execute("SELECT onec_id FROM onec_doctors WHERE doctor_id IS NOT NULL")
rows = cur.fetchall()
conn.close()
ids = [r[0] for r in rows if r[0]]
if ids:
return ids
except Exception as e:
print(f"[DB Warning] Could not fetch linked doctors from Postgres: {e}", flush=True)
# Fallback to known linked doctors
return [
"e27d7269-b659-11f1-8958-00155d46cd22",
"f07031a2-21a6-11f0-8712-00155d46cd22",
"a748a2aa-8771-11ed-840b-00155d46cd22",
"fce19e6d-3d52-11ec-adba-00155d46cd1d",
"137a17ba-0af6-11f0-86f5-00155d46cd22",
"a748a2a7-8771-11ed-840b-00155d46cd22",
]
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
def call_1c(code: str):
try:
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
resp = requests.post(MCP_URL, json={"code": code}, headers=headers, timeout=30)
if resp.status_code != 200:
return None, f"1C MCP returned HTTP {resp.status_code}: {resp.text[:200]}"
res_json = resp.json()
if not res_json.get("success", False):
return None, f"1C execution failed: {res_json.get('error', 'unknown error')}"
raw_data = res_json.get("data", "")
if isinstance(raw_data, str):
try:
parsed = json.loads(raw_data)
if isinstance(parsed, str) and (parsed.strip().startswith("{") or parsed.strip().startswith("[")):
try:
return json.loads(parsed), None
except Exception:
return parsed, None
return parsed, None
except Exception:
pass
return raw_data, None
except Exception as e:
return None, f"Network error connecting to 1C: {e}"
class BridgeHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
def send_json(self, status_code: int, data: any):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/")
query = urllib.parse.parse_qs(parsed.query)
if path == "/branches":
code = """
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| Филиалы.Ссылка КАК Ссылка,
| Филиалы.Наименование КАК Наименование
|ИЗ
| Справочник.Филиалы КАК Филиалы
|ГДЕ
| Филиалы.ПометкаУдаления = ЛОЖЬ
| И (Филиалы.Наименование ПОДОБНО ""%Дана%"" ИЛИ Филиалы.Наименование ПОДОБНО ""%Ляхова 22%"")
| И НЕ Филиалы.Наименование ПОДОБНО ""%Абилити%""
| И НЕ Филиалы.Наименование ПОДОБНО ""%Целитель%""
|УПОРЯДОЧИТЬ ПО
| Филиалы.Наименование";
Таблица = Запрос.Выполнить().Выгрузить();
Массив = Новый Массив;
Для Каждого Стр Из Таблица Цикл
Элемент = Новый Структура;
Элемент.Вставить("id", НРег(XMLСтрока(Стр.Ссылка)));
Элемент.Вставить("name", Стр.Наименование);
Адрес = "г. Махачкала, ул. Ляхова";
Если Найти(ВРег(Стр.Наименование), "22") > 0 Тогда
Адрес = "г. Махачкала, ул. Ляхова, д. 22";
ИначеЕсли Найти(ВРег(Стр.Наименование), "7") > 0 Тогда
Адрес = "г. Махачкала, ул. Ляхова, д. 7";
ИначеЕсли Найти(ВРег(Стр.Наименование), "ХАСАВЮРТ") > 0 Тогда
Адрес = "г. Хасавюрт";
ИначеЕсли Найти(ВРег(Стр.Наименование), "ЦОМ") > 0 ИЛИ Найти(ВРег(Стр.Наименование), "ОСТЕО") > 0 Тогда
Адрес = "г. Махачкала, ул. Салаватова, д. 53";
КонецЕсли;
Элемент.Вставить("address", Адрес);
Элемент.Вставить("phone", "+7 (8722) 55-33-11");
Элемент.Вставить("active", Истина);
Массив.Добавить(Элемент);
КонецЦикла;
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Массив);
Результат = ЗаписьJSON.Закрыть();
"""
data, err = call_1c(code)
if err:
self.send_json(502, {"code": "1c_error", "message": err})
else:
self.send_json(200, data or [])
return
if path == "/doctors":
code = """
Запрос1С = Новый Структура("ПараметрыЗапроса", Новый Соответствие);
Ответ = ДМ_СайтРасписание.ПолучитьВрачей(Запрос1С);
Результат = Ответ.ПолучитьТелоКакСтроку();
"""
data, err = call_1c(code)
if err:
self.send_json(502, {"code": "1c_error", "message": err})
else:
doctors = data or []
for d in doctors:
try:
d["kids_from"] = int(d.get("kids_from", 0))
except (ValueError, TypeError):
d["kids_from"] = 0
self.send_json(200, doctors)
return
if path.startswith("/doctors/") and path.endswith("/services"):
parts = path.split("/")
doctor_id = parts[2]
code = f"""
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ ПЕРВЫЕ 100
| Цены.Номенклатура.Ссылка КАК Ссылка,
| Цены.Номенклатура.Наименование КАК Наименование,
| Цены.Цена КАК Цена
|ИЗ
| РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период, ) КАК Цены
|ГДЕ
| Цены.Номенклатура.ПометкаУдаления = ЛОЖЬ
| И Цены.Цена > 0
| И (Цены.бит_Сотрудник = &Врач ИЛИ Цены.бит_Сотрудник = ЗНАЧЕНИЕ(Справочник.Сотрудники.ПустаяСсылка))
|УПОРЯДОЧИТЬ ПО
| Цены.Номенклатура.Наименование";
Запрос.УстановитьПараметр("Период", ТекущаяДата());
Запрос.УстановитьПараметр("Врач", ВрачСсылка);
Таблица = Запрос.Выполнить().Выгрузить();
Массив = Новый Массив;
Длит = ВрачСсылка.ДлительностьПриема;
ДлитМин = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
Если ДлитМин < 15 ИЛИ ДлитМин > 60 Тогда ДлитМин = 30; КонецЕсли;
Для Каждого Стр Из Таблица Цикл
Услуга = Новый Структура;
Услуга.Вставить("id", НРег(XMLСтрока(Стр.Ссылка)));
Услуга.Вставить("name", Стр.Наименование);
Услуга.Вставить("duration_min", ДлитМин);
Услуга.Вставить("price", Стр.Цена);
Массив.Добавить(Услуга);
КонецЦикла;
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Массив);
Результат = ЗаписьJSON.Закрыть();
"""
data, err = call_1c(code)
if err:
self.send_json(502, {"code": "1c_error", "message": err})
else:
services = data or []
# Guarantee types for Go unmarshaling
for s in services:
try:
s["duration_min"] = int(s.get("duration_min", 30))
except (ValueError, TypeError):
s["duration_min"] = 30
try:
s["price"] = float(s.get("price", 0.0))
except (ValueError, TypeError):
s["price"] = 0.0
self.send_json(200, services)
return
if path == "/schedule":
doctor_id = query.get("doctor_id", [""])[0]
from_str = query.get("from", [""])[0]
to_str = query.get("to", [""])[0]
branch_id = query.get("branch_id", [""])[0]
now = datetime.now()
dt_from = now
if from_str:
try:
dt_from = datetime.fromisoformat(from_str.replace("Z", "+00:00"))
except Exception:
pass
dt_to = dt_from
if to_str:
try:
dt_to = datetime.fromisoformat(to_str.replace("Z", "+00:00"))
except Exception:
pass
if dt_to <= dt_from:
from datetime import timedelta
dt_to = dt_from + timedelta(days=14)
# Determine target doctors
if doctor_id:
target_ids = [doctor_id]
else:
target_ids = get_linked_doctor_ids()
ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
if branch_id:
branch_clause = f'И Графики.Филиал = Справочники.Филиалы.ПолучитьСсылку(Новый УникальныйИдентификатор("{branch_id}"))'
else:
branch_clause = ""
code = f"""
МассивИД = Новый Массив;
"""
for x in target_ids:
code += f'МассивИД.Добавить(Новый УникальныйИдентификатор("{x}"));\n'
code += f"""
МассивСсылок = Новый Массив;
Для Каждого ИД Из МассивИД Цикл
Ссылка = Справочники.Сотрудники.ПолучитьСсылку(ИД);
МассивСсылок.Добавить(Ссылка);
КонецЦикла;
ДатаС = Дата({dt_from.year}, {dt_from.month}, {dt_from.day}, {dt_from.hour}, {dt_from.minute}, {dt_from.second});
ДатаПо = Дата({dt_to.year}, {dt_to.month}, {dt_to.day}, {dt_to.hour}, {dt_to.minute}, {dt_to.second});
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| Графики.Сотрудник КАК Сотрудник,
| Графики.Сотрудник.ДлительностьПриема КАК ДлительностьПриема,
| Графики.Дата КАК Дата,
| Графики.ВремяНачала КАК ВремяНачала,
| Графики.ВремяОкончания КАК ВремяОкончания,
| Графики.Кабинет КАК Кабинет,
| Графики.Кабинет.Наименование КАК КабинетИмя,
| Графики.Филиал КАК Филиал
|ИЗ
| РегистрСведений.ГрафикиРаботы КАК Графики
|ГДЕ
| Графики.Дата МЕЖДУ &ДатаС И &ДатаПо
| И Графики.Сотрудник В (&МассивВрачей)
| И Графики.Сотрудник.ПометкаУдаления = ЛОЖЬ
| И (Графики.Филиал.Наименование ПОДОБНО ""%Дана%"" ИЛИ Графики.Филиал.Наименование ПОДОБНО ""%Ляхова 22%"")
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Абилити%""
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Целитель%""
| {branch_clause}";
Запрос.УстановитьПараметр("МассивВрачей", МассивСсылок);
Запрос.УстановитьПараметр("ДатаС", ДатаС);
Запрос.УстановитьПараметр("ДатаПо", ДатаПо);
Таблица = Запрос.Выполнить().Выгрузить();
// Получаем существующие заявки для блокировки занятых слотов
ЗапросЗаявок = Новый Запрос;
ЗапросЗаявок.Текст =
"ВЫБРАТЬ
| Заявка.Сотрудник КАК Сотрудник,
| Заявка.ДатаНачала КАК ДатаНачала,
| Заявка.ДатаОкончания КАК ДатаОкончания
|ИЗ
| Документ.Заявка КАК Заявка
|ГДЕ
| Заявка.ПометкаУдаления = ЛОЖЬ
| И (Заявка.Состояние.ТипСостояния <> &ТипОтменена ИЛИ Заявка.Состояние.ТипСостояния ЕСТЬ NULL)
| И Заявка.Сотрудник В (&МассивВрачей)
| И Заявка.ДатаНачала < &ДатаПо
| И Заявка.ДатаОкончания > &ДатаС";
ЗапросЗаявок.УстановитьПараметр("МассивВрачей", МассивСсылок);
ЗапросЗаявок.УстановитьПараметр("ДатаС", ДатаС);
ЗапросЗаявок.УстановитьПараметр("ДатаПо", ДатаПо);
ЗапросЗаявок.УстановитьПараметр("ТипОтменена", Перечисления.ТипыСостоянийЗаявок.Отменена);
ТаблицаЗаявок = ЗапросЗаявок.Выполнить().Выгрузить();
Слоты = Новый Массив;
ТекущееВремя = ТекущаяДата();
Для Каждого Стр Из Таблица Цикл
Длит = Стр.ДлительностьПриема;
ШагМинут = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
Если ШагМинут < 15 ИЛИ ШагМинут > 60 Тогда ШагМинут = 30; КонецЕсли;
ШагСекунд = ШагМинут * 60;
ДатаСмены = НачалоДня(Стр.Дата);
НачалоСмены = ДатаСмены + (Стр.ВремяНачала - НачалоДня(Стр.ВремяНачала));
КонецСмены = ДатаСмены + (Стр.ВремяОкончания - НачалоДня(Стр.ВремяОкончания));
IDВрача = НРег(XMLСтрока(Стр.Сотрудник));
IDФилиала = НРег(XMLСтрока(Стр.Филиал));
ИмяКабинета = ?(ЗначениеЗаполнено(Стр.Кабинет), Стр.КабинетИмя, "");
Текущий = НачалоСмены;
Пока (Текущий + ШагСекунд) <= КонецСмены Цикл
СлотКонец = Текущий + ШагСекунд;
// Проверка на занятость заявками
Занят = Ложь;
Для Каждого СтрЗаявки Из ТаблицаЗаявок Цикл
Если СтрЗаявки.Сотрудник = Стр.Сотрудник И (СтрЗаявки.ДатаНачала < СлотКонец) И (СтрЗаявки.ДатаОкончания > Текущий) Тогда
Занят = Истина;
Прервать;
КонецЕсли;
КонецЦикла;
Слот = Новый Структура;
Слот.Вставить("doctor_id", IDВрача);
Слот.Вставить("branch_id", IDФилиала);
Слот.Вставить("cabinet", ИмяКабинета);
Слот.Вставить("start", Формат(Текущий, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
Слот.Вставить("end", Формат(СлотКонец, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
Слот.Вставить("status", ?(Занят, "busy", "free"));
Слот.Вставить("slot_id", IDВрача + "_" + Формат(Текущий, "ДФ=yyyyMMddHHmmss"));
Слоты.Добавить(Слот);
Текущий = СлотКонец;
КонецЦикла;
КонецЦикла;
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Слоты);
Результат = ЗаписьJSON.Закрыть();
"""
data, err = call_1c(code)
if err:
self.send_json(502, {"code": "1c_error", "message": err})
else:
self.send_json(200, data or [])
return
if path.startswith("/appointments/"):
appt_id = path.split("/")[2]
code = f"""
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
Ответ = Новый Структура;
Ответ.Вставить("appointment_id", "{appt_id}");
Ответ.Вставить("status", "booked");
Если ЗначениеЗаполнено(ДокСсылка) И Не ДокСсылка.Пустая() Тогда
Если ДокСсылка.ПометкаУдаления Тогда
Ответ.Вставить("status", "cancelled");
ИначеЕсли ДокСсылка.Состояние.ТипСостояния = Перечисления.ТипыСостоянийЗаявок.Отменена Тогда
Ответ.Вставить("status", "cancelled");
КонецЕсли;
Ответ.Вставить("start", Формат(ДокСсылка.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
Ответ.Вставить("end", Формат(ДокСсылка.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
КонецЕсли;
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Ответ);
Результат = ЗаписьJSON.Закрыть();
"""
data, err = call_1c(code)
if err:
self.send_json(502, {"code": "1c_error", "message": err})
else:
self.send_json(200, data or {"appointment_id": appt_id, "status": "booked"})
return
if path == "/changes":
self.send_json(200, {"version": 1, "doctor_ids": []})
return
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path.rstrip("/")
content_len = int(self.headers.get("Content-Length", 0))
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
try:
req_json = json.loads(post_data.decode("utf-8"))
except Exception:
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
return
if path == "/appointments":
doctor_id = req_json.get("doctor_id", "")
branch_id = req_json.get("branch_id", "")
patient_name = req_json.get("patient_name", "")
patient_phone = req_json.get("patient_phone", "")
comment = req_json.get("comment", "")
start_str = req_json.get("start", "")
if not start_str:
start_str = req_json.get("start_time", "")
if "patient" in req_json and isinstance(req_json["patient"], dict):
p = req_json["patient"]
if not patient_name:
patient_name = p.get("name") or f"{p.get('last_name','')} {p.get('first_name','')} {p.get('middle_name','')}".strip()
if not patient_phone:
patient_phone = p.get("phone", "")
if not comment:
comment = p.get("comment", "")
if not patient_name:
patient_name = "Пациент с сайта"
# Normalize phone digits and generate search patterns
digits = "".join([c for c in patient_phone if c.isdigit()])
if len(digits) >= 10:
tail = digits[-10:]
d = list(tail)
pattern_delims = f"%{d[0]}{d[1]}{d[2]}%{d[3]}{d[4]}{d[5]}%{d[6]}{d[7]}%{d[8]}{d[9]}%"
pattern_solid = f"%{tail}%"
else:
pattern_delims = f"%{digits}%"
pattern_solid = f"%{digits}%"
code = f"""
Попытка
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
// 1. Поиск существующего клиента по номеру телефона и подмена на него
ЗапросКл = Новый Запрос;
ЗапросКл.Текст =
"ВЫБРАТЬ ПЕРВЫЕ 1
| КИ.Объект КАК Клиент
|ИЗ
| РегистрСведений.КонтактнаяИнформация КАК КИ
|ГДЕ
| КИ.Тип = ЗНАЧЕНИЕ(Перечисление.ТипыКонтактнойИнформации.Телефон)
| И КИ.Объект ССЫЛКА Справочник.Клиенты
| И КИ.Объект.ПометкаУдаления = ЛОЖЬ
| И (КИ.Представление ПОДОБНО &ШаблонРазд
| ИЛИ КИ.Представление ПОДОБНО &ШаблонСплош
| ИЛИ КИ.Поле3 ПОДОБНО &ШаблонРазд
| ИЛИ КИ.Поле3 ПОДОБНО &ШаблонСплош)";
ЗапросКл.УстановитьПараметр("ШаблонРазд", "{pattern_delims}");
ЗапросКл.УстановитьПараметр("ШаблонСплош", "{pattern_solid}");
ВыбКл = ЗапросКл.Выполнить().Выбрать();
КлиентСсылка = Неопределено;
Если ВыбКл.Следующий() И ЗначениеЗаполнено(ВыбКл.Клиент) Тогда
// Подменяем на найденного клиента из базы 1С
КлиентСсылка = ВыбКл.Клиент;
Иначе
// Клиент не найден -> создаем нового
ОбъектКл = Справочники.Клиенты.СоздатьЭлемент();
ОбъектКл.Наименование = "{patient_name}";
ОбъектКл.Комментарий = "Создан через сайт danamedical.ru";
ОбъектКл.Записать();
КлиентСсылка = ОбъектКл.Ссылка;
Попытка
ВидКИ = Неопределено;
Попытка
ВидКИ = Справочники.ВидыКонтактнойИнформации.ТелефонСотовый;
Исключение
ВидКИ = Справочники.ВидыКонтактнойИнформации.НайтиПоНаименованию("Телефон сотовый");
КонецПопытки;
МЗ = РегистрыСведений.КонтактнаяИнформация.СоздатьМенеджерЗаписи();
МЗ.Объект = КлиентСсылка;
МЗ.Тип = Перечисления.ТипыКонтактнойИнформации.Телефон;
Если ЗначениеЗаполнено(ВидКИ) Тогда
МЗ.Вид = ВидКИ;
КонецЕсли;
МЗ.Представление = "{patient_phone}";
МЗ.Поле3 = "{patient_phone}";
МЗ.Записать(Истина);
Исключение
КонецПопытки;
КонецЕсли;
// Время
ВремяНач = ДМ_СайтAPI.РазобратьISO8601("{start_str}");
Если Не ЗначениеЗаполнено(ВремяНач) Тогда
ВремяНач = ТекущаяДата();
КонецЕсли;
Длит = ВрачСсылка.ДлительностьПриема;
ШагМинут = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
Если ШагМинут < 15 ИЛИ ШагМинут > 60 Тогда ШагМинут = 30; КонецЕсли;
// Определение филиала (строго клиника сети Дана)
ФилиалСсылка = Неопределено;
Если ЗначениеЗаполнено("{branch_id}") Тогда
Попытка
ФилиалКанд = Справочники.Филиалы.ПолучитьСсылку(Новый УникальныйИдентификатор("{branch_id}"));
Если ЗначениеЗаполнено(ФилиалКанд) И Не ФилиалКанд.Пустая() Тогда
ИмяФ = ВРег(ФилиалКанд.Наименование);
Если (Найти(ИмяФ, "ДАНА") > 0 ИЛИ Найти(ИмяФ, "ЛЯХОВА 22") > 0)
И Найти(ИмяФ, "АБИЛИТИ") = 0
И Найти(ИмяФ, "ЦЕЛИТЕЛЬ") = 0 Тогда
ФилиалСсылка = ФилиалКанд;
КонецЕсли;
КонецЕсли;
Исключение
КонецПопытки;
КонецЕсли;
Если Не ЗначениеЗаполнено(ФилиалСсылка) Тогда
ЗапросСмены = Новый Запрос;
ЗапросСмены.Текст =
"ВЫБРАТЬ ПЕРВЫЕ 1
| Графики.Филиал КАК Филиал
|ИЗ
| РегистрСведений.ГрафикиРаботы КАК Графики
|ГДЕ
| Графики.Сотрудник = &Врач
| И Графики.Дата = НАЧАЛОПЕРИОДА(&ВремяНач, ДЕНЬ)
| И (Графики.Филиал.Наименование ПОДОБНО ""%Дана%"" ИЛИ Графики.Филиал.Наименование ПОДОБНО ""%Ляхова 22%"")
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Абилити%""
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Целитель%"" ";
ЗапросСмены.УстановитьПараметр("Врач", ВрачСсылка);
ЗапросСмены.УстановитьПараметр("ВремяНач", ВремяНач);
ВыбСмены = ЗапросСмены.Выполнить().Выбрать();
Если ВыбСмены.Следующий() Тогда
ФилиалСсылка = ВыбСмены.Филиал;
КонецЕсли;
КонецЕсли;
Если Не ЗначениеЗаполнено(ФилиалСсылка) Тогда
ЗапросФ = Новый Запрос(
"ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Справочник.Филиалы КАК Филиалы
|ГДЕ Филиалы.ПометкаУдаления = ЛОЖЬ
| И (Филиалы.Наименование ПОДОБНО ""%Дана%"" ИЛИ Филиалы.Наименование ПОДОБНО ""%Ляхова 22%"")
| И НЕ Филиалы.Наименование ПОДОБНО ""%Абилити%""
| И НЕ Филиалы.Наименование ПОДОБНО ""%Целитель%"" ");
ВыбФ = ЗапросФ.Выполнить().Выбрать();
Если ВыбФ.Следующий() Тогда
ФилиалСсылка = ВыбФ.Ссылка;
КонецЕсли;
КонецЕсли;
// Создаем Заявку
Док = Документы.Заявка.СоздатьДокумент();
Док.Дата = ТекущаяДата();
Док.ДатаНачала = ВремяНач;
Док.ДатаОкончания = ВремяНач + ШагМинут * 60;
Док.ВремяНачала = Док.ДатаНачала;
Док.ВремяОкончания = Док.ДатаОкончания;
Док.Сотрудник = ВрачСсылка;
Док.Филиал = ФилиалСсылка;
Док.Клиент = КлиентСсылка;
Док.Состояние = Справочники.ВидыСостоянийЗаявок.Запланирована;
Док.Примечание = "Онлайн-запись: {comment}";
Ист = Справочники.ИсточникиИнформации.НайтиПоНаименованию("Сайт");
Если ЗначениеЗаполнено(Ист) Тогда
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
КонецЕсли;
Док.Записать(РежимЗаписиДокумента.Запись);
ID = НРег(XMLСтрока(Док.Ссылка));
Ответ = Новый Структура;
Ответ.Вставить("appointment_id", ID);
Ответ.Вставить("status", "booked");
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
Ответ.Вставить("branch_id", НРег(XMLСтрока(ФилиалСсылка)));
Ответ.Вставить("patient_id", НРег(XMLСтрока(КлиентСсылка)));
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Ответ);
Результат = ЗаписьJSON.Закрыть();
Исключение
Результат = "ОШИБКА: " + ОписаниеОшибки();
КонецПопытки;
"""
data, err = call_1c(code)
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
self.send_json(500, {"code": "booking_failed", "message": err or data})
else:
self.send_json(201, data)
return
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
def run():
server_address = ("0.0.0.0", 8088)
httpd = HTTPServer(server_address, BridgeHandler)
print("Bridge server running on port 8088 with live Postgres link...", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -38,7 +38,13 @@ function hourSlots(hours) {
let from = m ? Number(m[1]) : 8; let from = m ? Number(m[1]) : 8;
let to = m ? Number(m[2]) : 20; let to = m ? Number(m[2]) : 20;
if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20]; if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20];
return Array.from({ length: to - from }, (_, i) => `${String(from + i).padStart(2, '0')}:00`); const list = [];
for (let h = from; h < to; h++) {
const hh = String(h).padStart(2, '0');
list.push(`${hh}:00`);
list.push(`${hh}:30`);
}
return list;
} }
function todayISO() { function todayISO() {
@ -87,11 +93,14 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
})); }));
}; };
// на сегодня — только часы, до которых ещё больше часа // на сегодня — только слоты в будущем
const isPast = (slot) => { const isPast = (slot) => {
if (values.date !== todayISO()) return false; if (values.date !== todayISO()) return false;
const now = new Date(); const now = new Date();
return Number(slot.slice(0, 2)) <= now.getHours(); const [h, m] = slot.split(':').map(Number);
const slotMinutes = h * 60 + (m || 0);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
return slotMinutes <= nowMinutes;
}; };
useEffect(() => { useEffect(() => {
@ -313,7 +322,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
})} })}
</div> </div>
) : ( ) : (
<p className="lead-slots__hint">Сначала выберите дату — покажем время по часам</p> <p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
)} )}
</div> </div>
</> </>

View File

@ -46,6 +46,17 @@ import promoKindergarten from '../assets/figma/promo-sad.webp';
import promoBaby from '../assets/figma/promo-rodilsya.webp'; import promoBaby from '../assets/figma/promo-rodilsya.webp';
import checkupHeroGirl from '../assets/figma/checkup-hero-girl.webp'; import checkupHeroGirl from '../assets/figma/checkup-hero-girl.webp';
// Вырезанные фигуры для шапки чек-апа (прозрачный фон, 540 px по высоте шапки), 14.10.2026
import cutCervix from '../assets/checkups/hero-profilaktika-raka-sheyki-matki.webp';
import cutPostpartum from '../assets/checkups/hero-poslerodovoy-chek-ap-cherez-mesyats.webp';
import cutPlanning from '../assets/checkups/hero-planirovannie-beremennosti.webp';
import cutWoman40 from '../assets/checkups/hero-zdorovaya-zhenshchina-40.webp';
import cutWomanHealth from '../assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp';
import cutTrimester1 from '../assets/checkups/hero-vedenie-beremennosti-1-trimestr.webp';
import cutTrimester2 from '../assets/checkups/hero-vedenie-beremennosti-2-trim.webp';
import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp';
import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp';
import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp';
// Направления: фото для плитки и специальность, врачей которой показываем на странице услуги // Направления: фото для плитки и специальность, врачей которой показываем на странице услуги
// (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности). // (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности).
@ -88,11 +99,12 @@ export function directionImage(service) {
const CHECKUP_MEDIA = { const CHECKUP_MEDIA = {
'zdorovaya-devoch-a': { photo: ckGirl, icon: icoFemale, hero: checkupHeroGirl }, 'zdorovaya-devoch-a': { photo: ckGirl, icon: icoFemale, hero: checkupHeroGirl },
'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup }, 'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup, cutout: cutWomanHealth },
'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant }, 'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant, cutout: cutPlanning },
'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon }, 'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon, cutout: cutCervix },
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer }, // серо-синий фон шапки (#7886a9) — давление и родители
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily }, 'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, heroBg: '#7886a9' },
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, heroBg: '#7886a9' },
'mne-god': { photo: ckYear, icon: icoBaby }, 'mne-god': { photo: ckYear, icon: icoBaby },
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground }, 'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground },
pervoklashka: { photo: ckSchool, icon: icoSchool }, pervoklashka: { photo: ckSchool, icon: icoSchool },
@ -100,11 +112,11 @@ const CHECKUP_MEDIA = {
detskiy: { photo: ckMini, icon: icoStethoscope }, detskiy: { photo: ckMini, icon: icoStethoscope },
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach }, 'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach },
// программ ниже в макете нет — берём близкие по смыслу фото макета // программ ниже в макете нет — берём близкие по смыслу фото макета
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant }, 'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1 },
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant }, 'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2 },
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant }, 'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3 },
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale }, 'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum },
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale }, 'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40 },
'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby }, 'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby },
}; };
@ -117,6 +129,10 @@ export function checkupMedia(checkup) {
// крупный кадр наверху страницы — только отдельный большой снимок: фото карточки // крупный кадр наверху страницы — только отдельный большой снимок: фото карточки
// (360×270) в блоке 364×485 было бы размытым // (360×270) в блоке 364×485 было бы размытым
hero: m.hero || '', hero: m.hero || '',
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
cutout: m.cutout || '',
// свой цвет фона шапки, если в макете он не коралловый
heroBg: m.heroBg || '',
}; };
} }

View File

@ -12,6 +12,12 @@
background: var(--orange); background: var(--orange);
} }
/* программы с вырезанной фигурой: женщины, беременность, гинекология — #f08372;
другой цвет задаёт heroBg в media.js (давление, родители — #7886a9). Детские — --orange (#ee7203). */
.checkup-hero--cutout {
background: var(--checkup-hero-bg, #f08372);
}
.checkup-hero__row { .checkup-hero__row {
position: relative; position: relative;
display: flex; display: flex;
@ -67,6 +73,12 @@
max-width: 570px; max-width: 570px;
font-size: 18px; font-size: 18px;
line-height: 28px; line-height: 28px;
/* в шапке — короткое описание; полный список анализов — ниже, в «Что входит в программу» */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
} }
.checkup-hero__actions { .checkup-hero__actions {
@ -429,3 +441,70 @@
font-size: 12px; font-size: 12px;
} }
} }
/* ---------- вырезанная фигура в шапке ----------
Фото без фона справа на всю высоту шапки, низом к краю, как в макете. Широкий снимок
уменьшается целиком (без обрезки), чтобы не заходить на текст; справа может выходить за сетку. */
.checkup-hero__row--cutout .checkup-hero__copy {
max-width: 560px;
}
.checkup-hero__image--cutout {
position: absolute;
top: 0;
bottom: 0;
left: 592px;
right: min(0px, calc((100% - 100vw) / 2 + 80px));
width: auto;
height: auto;
overflow: visible;
pointer-events: none;
}
.checkup-hero__image--cutout img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
object-position: right bottom;
}
@media (max-width: 1023px) {
.checkup-hero__row--cutout .checkup-hero__copy {
max-width: 55%;
}
.checkup-hero__image--cutout {
left: 58%;
right: calc(-1 * var(--gutter));
width: auto;
height: auto;
}
}
/* телефон: фигура уходит за текст и приглушена, чтобы текст читался */
@media (max-width: 767px) {
.checkup-hero__row--cutout .checkup-hero__copy {
max-width: none;
}
.checkup-hero__image--cutout {
top: 0;
bottom: 0;
left: 18%;
right: calc(-1 * var(--gutter));
width: auto;
height: auto;
}
.checkup-hero__image--cutout img {
object-fit: cover;
object-position: 70% bottom;
opacity: 0.5;
}
}
@media (max-width: 767px) {
.checkup-hero__text {
-webkit-line-clamp: 6;
line-clamp: 6;
}
}

View File

@ -27,7 +27,7 @@ export default function CheckupPage() {
if (!checkup.data || checkup.data.slug !== slug) return <p className="state checkup-loading">Загружаем…</p>; if (!checkup.data || checkup.data.slug !== slug) return <p className="state checkup-loading">Загружаем…</p>;
const c = checkup.data; const c = checkup.data;
const { hero, photo } = checkupMedia(c); const { hero, photo, cutout, heroBg } = checkupMedia(c);
const items = c.items?.length ? c.items.map(cleanText) : checklistItems(c.blocks); const items = c.items?.length ? c.items.map(cleanText) : checklistItems(c.blocks);
const intro = introText(c.blocks); const intro = introText(c.blocks);
const lead = cleanText(c.lead); const lead = cleanText(c.lead);
@ -40,8 +40,11 @@ export default function CheckupPage() {
return ( return (
<div className="checkup-page"> <div className="checkup-page">
<section className="checkup-hero"> <section
<div className="container checkup-hero__row"> className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}`}
style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined}
>
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>
<div className="checkup-hero__copy"> <div className="checkup-hero__copy">
<Breadcrumbs <Breadcrumbs
items={[{ to: '/', label: 'Главная' }, { to: '/checkup/', label: 'Чек-апы' }, { label: name }]} items={[{ to: '/', label: 'Главная' }, { to: '/checkup/', label: 'Чек-апы' }, { label: name }]}
@ -60,7 +63,11 @@ export default function CheckupPage() {
</button> </button>
</div> </div>
</div> </div>
{hero ? ( {cutout ? (
<div className="checkup-hero__image checkup-hero__image--cutout">
<img src={cutout} alt="" />
</div>
) : hero ? (
<div className="checkup-hero__image"> <div className="checkup-hero__image">
<img src={hero} alt="" /> <img src={hero} alt="" />
</div> </div>