fix(booking): 30-min slot intervals and 1C MCP auth synchronization
6
bridge/Dockerfile
Normal 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
@ -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()
|
||||||
BIN
web/src/assets/checkups/hero-davlenie-v-poryadke.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
web/src/assets/checkups/hero-planirovannie-beremennosti.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 52 KiB |
BIN
web/src/assets/checkups/hero-profilaktika-raka-sheyki-matki.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
web/src/assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 77 KiB |
BIN
web/src/assets/checkups/hero-vedenie-beremennosti-2-trim.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
web/src/assets/checkups/hero-vedenie-beremennosti-3-trim.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/src/assets/checkups/hero-zdorovaya-zhenshchina-40.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
web/src/assets/checkups/hero-zdorovye-roditeli.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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 || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||