diff --git a/bridge/Dockerfile b/bridge/Dockerfile new file mode 100644 index 0000000..3d9389b --- /dev/null +++ b/bridge/Dockerfile @@ -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"] diff --git a/bridge/bridge_1c.py b/bridge/bridge_1c.py new file mode 100644 index 0000000..a91b6e9 --- /dev/null +++ b/bridge/bridge_1c.py @@ -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() diff --git a/web/src/assets/checkups/hero-davlenie-v-poryadke.webp b/web/src/assets/checkups/hero-davlenie-v-poryadke.webp new file mode 100644 index 0000000..3d3387f Binary files /dev/null and b/web/src/assets/checkups/hero-davlenie-v-poryadke.webp differ diff --git a/web/src/assets/checkups/hero-planirovannie-beremennosti.webp b/web/src/assets/checkups/hero-planirovannie-beremennosti.webp new file mode 100644 index 0000000..0534ee9 Binary files /dev/null and b/web/src/assets/checkups/hero-planirovannie-beremennosti.webp differ diff --git a/web/src/assets/checkups/hero-poslerodovoy-chek-ap-cherez-mesyats.webp b/web/src/assets/checkups/hero-poslerodovoy-chek-ap-cherez-mesyats.webp new file mode 100644 index 0000000..cc97a89 Binary files /dev/null and b/web/src/assets/checkups/hero-poslerodovoy-chek-ap-cherez-mesyats.webp differ diff --git a/web/src/assets/checkups/hero-profilaktika-raka-sheyki-matki.webp b/web/src/assets/checkups/hero-profilaktika-raka-sheyki-matki.webp new file mode 100644 index 0000000..7b4a971 Binary files /dev/null and b/web/src/assets/checkups/hero-profilaktika-raka-sheyki-matki.webp differ diff --git a/web/src/assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp b/web/src/assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp new file mode 100644 index 0000000..8f1ff8f Binary files /dev/null and b/web/src/assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp differ diff --git a/web/src/assets/checkups/hero-vedenie-beremennosti-1-trimestr.webp b/web/src/assets/checkups/hero-vedenie-beremennosti-1-trimestr.webp new file mode 100644 index 0000000..2af65f8 Binary files /dev/null and b/web/src/assets/checkups/hero-vedenie-beremennosti-1-trimestr.webp differ diff --git a/web/src/assets/checkups/hero-vedenie-beremennosti-2-trim.webp b/web/src/assets/checkups/hero-vedenie-beremennosti-2-trim.webp new file mode 100644 index 0000000..2a06bdb Binary files /dev/null and b/web/src/assets/checkups/hero-vedenie-beremennosti-2-trim.webp differ diff --git a/web/src/assets/checkups/hero-vedenie-beremennosti-3-trim.webp b/web/src/assets/checkups/hero-vedenie-beremennosti-3-trim.webp new file mode 100644 index 0000000..5b69f7b Binary files /dev/null and b/web/src/assets/checkups/hero-vedenie-beremennosti-3-trim.webp differ diff --git a/web/src/assets/checkups/hero-zdorovaya-zhenshchina-40.webp b/web/src/assets/checkups/hero-zdorovaya-zhenshchina-40.webp new file mode 100644 index 0000000..bf563f3 Binary files /dev/null and b/web/src/assets/checkups/hero-zdorovaya-zhenshchina-40.webp differ diff --git a/web/src/assets/checkups/hero-zdorovye-roditeli.webp b/web/src/assets/checkups/hero-zdorovye-roditeli.webp new file mode 100644 index 0000000..05e9474 Binary files /dev/null and b/web/src/assets/checkups/hero-zdorovye-roditeli.webp differ diff --git a/web/src/components/LeadModal.jsx b/web/src/components/LeadModal.jsx index e115b2c..4cfc9aa 100644 --- a/web/src/components/LeadModal.jsx +++ b/web/src/components/LeadModal.jsx @@ -38,7 +38,13 @@ function hourSlots(hours) { let from = m ? Number(m[1]) : 8; let to = m ? Number(m[2]) : 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() { @@ -87,11 +93,14 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, })); }; - // на сегодня — только часы, до которых ещё больше часа + // на сегодня — только слоты в будущем const isPast = (slot) => { if (values.date !== todayISO()) return false; 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(() => { @@ -313,7 +322,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, })} ) : ( -

Сначала выберите дату — покажем время по часам

+

Сначала выберите дату — покажем время приёма

)} diff --git a/web/src/lib/media.js b/web/src/lib/media.js index 25fb89e..93d954a 100644 --- a/web/src/lib/media.js +++ b/web/src/lib/media.js @@ -46,6 +46,17 @@ import promoKindergarten from '../assets/figma/promo-sad.webp'; import promoBaby from '../assets/figma/promo-rodilsya.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 = { 'zdorovaya-devoch-a': { photo: ckGirl, icon: icoFemale, hero: checkupHeroGirl }, - 'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup }, - 'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant }, - 'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon }, - 'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer }, - 'zdorovye-roditeli': { photo: ckParents, icon: icoFamily }, + 'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup, cutout: cutWomanHealth }, + 'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant, cutout: cutPlanning }, + 'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon, cutout: cutCervix }, + // серо-синий фон шапки (#7886a9) — давление и родители + '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 }, 'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground }, pervoklashka: { photo: ckSchool, icon: icoSchool }, @@ -100,11 +112,11 @@ const CHECKUP_MEDIA = { detskiy: { photo: ckMini, icon: icoStethoscope }, 'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach }, // программ ниже в макете нет — берём близкие по смыслу фото макета - 'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant }, - 'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant }, - 'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant }, - 'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale }, - 'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale }, + 'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1 }, + 'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2 }, + 'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3 }, + 'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum }, + 'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40 }, 'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby }, }; @@ -117,6 +129,10 @@ export function checkupMedia(checkup) { // крупный кадр наверху страницы — только отдельный большой снимок: фото карточки // (360×270) в блоке 364×485 было бы размытым hero: m.hero || '', + // вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки + cutout: m.cutout || '', + // свой цвет фона шапки, если в макете он не коралловый + heroBg: m.heroBg || '', }; } diff --git a/web/src/pages/CheckupPage.css b/web/src/pages/CheckupPage.css index 3349014..195e13b 100644 --- a/web/src/pages/CheckupPage.css +++ b/web/src/pages/CheckupPage.css @@ -12,6 +12,12 @@ background: var(--orange); } +/* программы с вырезанной фигурой: женщины, беременность, гинекология — #f08372; + другой цвет задаёт heroBg в media.js (давление, родители — #7886a9). Детские — --orange (#ee7203). */ +.checkup-hero--cutout { + background: var(--checkup-hero-bg, #f08372); +} + .checkup-hero__row { position: relative; display: flex; @@ -67,6 +73,12 @@ max-width: 570px; font-size: 18px; line-height: 28px; + /* в шапке — короткое описание; полный список анализов — ниже, в «Что входит в программу» */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + overflow: hidden; } .checkup-hero__actions { @@ -429,3 +441,70 @@ 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; + } +} diff --git a/web/src/pages/CheckupPage.jsx b/web/src/pages/CheckupPage.jsx index cb8e05d..4d78c42 100644 --- a/web/src/pages/CheckupPage.jsx +++ b/web/src/pages/CheckupPage.jsx @@ -27,7 +27,7 @@ export default function CheckupPage() { if (!checkup.data || checkup.data.slug !== slug) return

Загружаем…

; 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 intro = introText(c.blocks); const lead = cleanText(c.lead); @@ -40,8 +40,11 @@ export default function CheckupPage() { return (
-
-
+
+
- {hero ? ( + {cutout ? ( +
+ +
+ ) : hero ? (