#!/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 os import re import json import hmac import urllib.parse from http.server import HTTPServer, BaseHTTPRequestHandler from datetime import datetime, timezone import requests import psycopg2 MCP_URL = os.environ.get("ONEC_MCP_URL", "http://10.240.70.70:6003/api/execute_code") PG_HOST = os.environ.get("PG_HOST", "db") PG_PORT = int(os.environ.get("PG_PORT", "5432")) PG_USER = os.environ.get("PG_USER", "dana") PG_DB = os.environ.get("PG_DB", "dana") PG_PASS = os.environ.get("POSTGRES_PASSWORD", "") # Токен для вызова 1C MCP (Bearer) и ключ, которым Go-бэкенд авторизуется перед мостом. AUTH_TOKEN = os.environ.get("ONEC_MCP_TOKEN", "") BRIDGE_API_KEY = os.environ.get("BRIDGE_API_KEY", "") if not AUTH_TOKEN: sys.exit("ONEC_MCP_TOKEN is not set") if not BRIDGE_API_KEY: sys.exit("BRIDGE_API_KEY is not set") MAX_BODY = 64 * 1024 UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") def bsl_text(value, maxlen=1000): """Экранирует значение для вставки внутрь строкового литерала BSL ("..."). В 1С кавычка внутри строки удваивается. Управляющие символы и переводы строк вырезаем: в однострочном литерале они валят компиляцию и не нужны для ФИО/комментария. """ v = "".join(c for c in str(value) if c.isprintable())[:maxlen] return v.replace('"', '""') def is_uuid(value): return isinstance(value, str) and bool(UUID_RE.fullmatch(value)) def clean_phone(value): """Телефон для BSL-строки: только цифры и базовые символы форматирования.""" v = re.sub(r"[^\d+\-() ]", "", str(value))[:40] return v 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", ] 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 _authorized(self): """Мост принимает запросы только от Go-бэкенда: X-Api-Key или Bearer.""" got = self.headers.get("X-Api-Key", "") if not got: got = self.headers.get("Authorization", "").removeprefix("Bearer ").strip() return bool(got) and hmac.compare_digest(got.encode("utf-8"), BRIDGE_API_KEY.encode("utf-8")) def _require_auth(self): if self._authorized(): return True self.send_json(401, {"code": "unauthorized", "message": "Invalid API key"}) return False 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 == "/healthz": self.send_json(200, {"ok": True}) return if not self._require_auth(): return 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: slots = data or [] while isinstance(slots, str): try: slots = json.loads(slots) except Exception: break if not isinstance(slots, list): slots = [] self.send_json(200, slots) 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 [] while isinstance(doctors, str): try: doctors = json.loads(doctors) except Exception: break if not isinstance(doctors, list): doctors = [] for d in doctors: if isinstance(d, dict): 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] if not is_uuid(doctor_id): self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) return 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 [] while isinstance(services, str): try: services = json.loads(services) except Exception: break if not isinstance(services, list): services = [] for s in services: if isinstance(s, dict): 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: if not is_uuid(doctor_id): self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) return target_ids = [doctor_id] else: target_ids = get_linked_doctor_ids() target_ids = [x for x in target_ids if is_uuid(x)] if branch_id and not is_uuid(branch_id): self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"}) return 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:ss") + "+03:00"); Слот.Вставить("end", Формат(СлотКонец, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Слот.Вставить("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] if not is_uuid(appt_id): self.send_json(400, {"code": "bad_request", "message": "appointment_id must be a UUID"}) return code = f""" ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}")); Ответ = Новый Структура; Ответ.Вставить("appointment_id", "{appt_id}"); Ответ.Вставить("status", "booked"); Если ЗначениеЗаполнено(ДокСсылка) И Не ДокСсылка.Пустая() Тогда Если ДокСсылка.ПометкаУдаления Тогда Ответ.Вставить("status", "cancelled"); ИначеЕсли ДокСсылка.Состояние.ТипСостояния = Перечисления.ТипыСостоянийЗаявок.Отменена Тогда Ответ.Вставить("status", "cancelled"); КонецЕсли; Ответ.Вставить("start", Формат(ДокСсылка.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("end", Формат(ДокСсылка.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); КонецЕсли; Запись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("/") if not self._require_auth(): return try: content_len = int(self.headers.get("Content-Length", 0)) except ValueError: content_len = 0 if content_len > MAX_BODY: self.send_json(413, {"code": "too_large", "message": "Request body too large"}) return 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 not isinstance(req_json, dict): self.send_json(400, {"code": "bad_request", "message": "JSON object required"}) return if path == "/appointments": doctor_id = req_json.get("doctor_id", "") bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200) 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 = "Пациент с сайта" # Валидация до сборки BSL-кода: ID — строго UUID, дата — ISO 8601, # свободный текст — через bsl_text (удвоение кавычек + срез управляющих). if not is_uuid(doctor_id): self.send_json(400, {"code": "bad_request", "message": "doctor_id must be a UUID"}) return if branch_id and not is_uuid(branch_id): self.send_json(400, {"code": "bad_request", "message": "branch_id must be a UUID"}) return try: start_str = datetime.fromisoformat(str(start_str).replace("Z", "+00:00")).isoformat() except (ValueError, TypeError): self.send_json(400, {"code": "bad_request", "message": "start must be ISO 8601"}) return patient_name = bsl_text(patient_name, 200) patient_phone = clean_phone(patient_phone) comment = bsl_text(comment, 2000) start_str = bsl_text(start_str, 40) # 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}"; Ист = Справочники.ИсточникиИнформации.НайтиПоНаименованию("Сайт"); Если ЗначениеЗаполнено(Ист) Тогда Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки; КонецЕсли; Если ЗначениеЗаполнено("{bitrix_lead_id}") Тогда Попытка Док.бит_Б24_СозданИзБитрикс24 = Истина; Док.бит_Б24_БитриксИД = "{bitrix_lead_id}"; Док.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; Исключение КонецПопытки; КонецЕсли; Док.Записать(РежимЗаписиДокумента.Запись); ID = НРег(XMLСтрока(Док.Ссылка)); ID_B64 = Base64Строка(ПолучитьДвоичныеДанныеИзСтроки(ID)); Ответ = Новый Структура; Ответ.Вставить("appointment_id", ID); Ответ.Вставить("appointment_id_b64", ID_B64); Ответ.Вставить("number", Док.Номер); Ответ.Вставить("status", "booked"); Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ss") + "+03:00"); Ответ.Вставить("branch_id", НРег(XMLСтрока(ФилиалСсылка))); Ответ.Вставить("patient_id", НРег(XMLСтрока(КлиентСсылка))); ЗаписьJSON = Новый ЗаписьJSON; ЗаписьJSON.УстановитьСтроку(); ЗаписатьJSON(ЗаписьJSON, Ответ); Результат = ЗаписьJSON.Закрыть(); Исключение Результат = "ОШИБКА: " + ОписаниеОшибки(); КонецПопытки; """ data, err = call_1c(code) if isinstance(data, dict) and "appointment_id_b64" in data: import base64 try: data["appointment_id"] = base64.b64decode(data["appointment_id_b64"]).decode("utf-8").strip() except Exception: pass 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 if path == "/appointments/link-bitrix": # Legacy linking supports UUIDs and document numbers: escape both. appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) bitrix_lead_id = bsl_text(str(req_json.get("bitrix_lead_id", "")).strip(), 200) if not appointment_id or not bitrix_lead_id: self.send_json(400, {"code": "bad_request", "message": "appointment_id and bitrix_lead_id are required"}) return code = f""" Попытка ДокСсылка = Неопределено; Попытка ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); Исключение КонецПопытки; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Попытка ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); Исключение КонецПопытки; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Запрос.УстановитьПараметр("Номер", "{appointment_id}"); Выб = Запрос.Выполнить().Выбрать(); Если Выб.Следующий() Тогда ДокСсылка = Выб.Ссылка; КонецЕсли; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Выб = Запрос.Выполнить().Выбрать(); Пока Выб.Следующий() Цикл СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда ДокСсылка = Выб.Ссылка; Прервать; КонецЕсли; КонецЦикла; КонецЕсли; Если ЗначениеЗаполнено(ДокСсылка) Тогда ДокОбъект = ДокСсылка.ПолучитьОбъект(); Если ДокОбъект <> Неопределено Тогда ДокОбъект.бит_Б24_СозданИзБитрикс24 = Истина; ДокОбъект.бит_Б24_БитриксИД = "{bitrix_lead_id}"; ДокОбъект.бит_НомерКлиентаБ24 = "{bitrix_lead_id}"; ДокОбъект.Записать(РежимЗаписиДокумента.Запись); Результат = "OK"; Иначе Результат = "NOT_FOUND"; КонецЕсли; Иначе Результат = "NOT_FOUND"; КонецЕсли; Исключение Результат = "ОШИБКА: " + ОписаниеОшибки(); КонецПопытки; """ data, err = call_1c(code) if err or (isinstance(data, str) and data.startswith("ОШИБКА")): self.send_json(500, {"code": "link_failed", "message": err or data}) elif data == "NOT_FOUND": self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) else: self.send_json(200, {"status": "ok", "message": "Связано успешно"}) return if path == "/appointments/link-crm": appointment_id = bsl_text(str(req_json.get("appointment_id", "")).strip(), 200) crm_deal_id = bsl_text(str(req_json.get("crm_deal_id", "")).strip(), 200) if not appointment_id or not crm_deal_id: self.send_json(400, {"code": "bad_request", "message": "appointment_id and crm_deal_id are required"}) return code = f""" Попытка ДокСсылка = Неопределено; Попытка ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); Исключение КонецПопытки; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Попытка ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); Исключение КонецПопытки; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Запрос.УстановитьПараметр("Номер", "{appointment_id}"); Выб = Запрос.Выполнить().Выбрать(); Если Выб.Следующий() Тогда ДокСсылка = Выб.Ссылка; КонецЕсли; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Выб = Запрос.Выполнить().Выбрать(); Пока Выб.Следующий() Цикл СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда ДокСсылка = Выб.Ссылка; Прервать; КонецЕсли; КонецЦикла; КонецЕсли; Если ЗначениеЗаполнено(ДокСсылка) Тогда ДокОбъект = ДокСсылка.ПолучитьОбъект(); Если ДокОбъект <> Неопределено Тогда Если Найти(ДокОбъект.Примечание, "{crm_deal_id}") = 0 Тогда ДокОбъект.Примечание = СокрЛП(ДокОбъект.Примечание) + " [CRM: {crm_deal_id}]"; КонецЕсли; ДокОбъект.Записать(РежимЗаписиДокумента.Запись); Результат = "OK"; Иначе Результат = "NOT_FOUND"; КонецЕсли; Иначе Результат = "NOT_FOUND"; КонецЕсли; Исключение Результат = "ОШИБКА: " + ОписаниеОшибки(); КонецПопытки; """ data, err = call_1c(code) if err or (isinstance(data, str) and data.startswith("ОШИБКА")): self.send_json(500, {"code": "link_failed", "message": err or data}) elif data == "NOT_FOUND": self.send_json(404, {"code": "not_found", "message": "Запись в 1С не найдена"}) else: self.send_json(200, {"status": "ok", "message": "Связано успешно с CRM"}) return self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"}) def do_DELETE(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") if not self._require_auth(): return parts = path.split("/") if len(parts) == 3 and parts[1] == "appointments": appointment_id = bsl_text(urllib.parse.unquote(parts[2]).strip(), 200) code = f""" Попытка ДокСсылка = Неопределено; Попытка ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appointment_id}")); Исключение КонецПопытки; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Попытка ДокСсылка = Документы.Заявка.НайтиПоНомеру("{appointment_id}", ТекущаяДата()); Исключение КонецПопытки; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Документ.Заявка ГДЕ Номер = &Номер УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Запрос.УстановитьПараметр("Номер", "{appointment_id}"); Выб = Запрос.Выполнить().Выбрать(); Если Выб.Следующий() Тогда ДокСсылка = Выб.Ссылка; КонецЕсли; КонецЕсли; Если Не ЗначениеЗаполнено(ДокСсылка) ИЛИ ДокСсылка.ПолучитьОбъект() = Неопределено Тогда Запрос = Новый Запрос; Запрос.Текст = "ВЫБРАТЬ ПЕРВЫЕ 30 Ссылка ИЗ Документ.Заявка УПОРЯДОЧИТЬ ПО Дата УБЫВ"; Выб = Запрос.Выполнить().Выбрать(); Пока Выб.Следующий() Цикл СтрGUID = НРег(XMLСтрока(Выб.Ссылка)); Если Найти("{appointment_id}", СтрGUID) > 0 ИЛИ (СтрДлина("{appointment_id}") > 20 И Найти(СтрGUID, Прав("{appointment_id}", 25)) > 0) Тогда ДокСсылка = Выб.Ссылка; Прервать; КонецЕсли; КонецЦикла; КонецЕсли; Если ЗначениеЗаполнено(ДокСсылка) Тогда ДокОбъект = ДокСсылка.ПолучитьОбъект(); Если ДокОбъект <> Неопределено Тогда Попытка ДокОбъект.Состояние = Справочники.ВидыСостоянийЗаявок.Отменена; Исключение КонецПопытки; ДокОбъект.ПометкаУдаления = Истина; ДокОбъект.Записать(РежимЗаписиДокумента.Запись); Результат = "OK"; Иначе Результат = "NOT_FOUND"; КонецЕсли; Иначе Результат = "NOT_FOUND"; КонецЕсли; Исключение Результат = "ОШИБКА: " + ОписаниеОшибки(); КонецПопытки; """ data, err = call_1c(code) if err or (isinstance(data, str) and data.startswith("ОШИБКА")): self.send_json(500, {"code": "cancel_failed", "message": err or data}) elif data == "NOT_FOUND": self.send_json(404, {"code": "not_found", "message": "Заявка в 1С не найдена"}) else: self.send_json(200, {"status": "cancelled", "message": "Запись отменена в 1С"}) 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()