diff --git a/bot/operations/statistics.py b/bot/operations/statistics.py index 66842dfc..f71fa729 100644 --- a/bot/operations/statistics.py +++ b/bot/operations/statistics.py @@ -16,6 +16,7 @@ from touchh.utils.log import CustomLogger +logger = CustomLogger(__name__).get_logger() async def statistics(update: Update, context: ContextTypes.DEFAULT_TYPE): """Вывод списка отелей для статистики.""" @@ -37,7 +38,7 @@ async def statistics(update: Update, context: ContextTypes.DEFAULT_TYPE): # Формируем кнопки для выбора отеля keyboard = [ - [InlineKeyboardButton(hotel.hotel.name, callback_data=f"stats_hotel_{hotel.hotel.id}")] + [InlineKeyboardButton(f'🏨 {hotel.hotel.name}', callback_data=f"stats_hotel_{hotel.hotel.id}")] for hotel in user_hotels ] keyboard.append([InlineKeyboardButton("🏠 Главная", callback_data="main_menu")]) @@ -91,53 +92,8 @@ def ensure_datetime(value): logging.warning(f"Получено значение неизвестного типа для преобразования в datetime: {value}") return None -# async def generate_statistics(update: Update, context: ContextTypes.DEFAULT_TYPE): -# """Генерация и отправка статистики.""" -# query = update.callback_query -# await query.answer() - -# try: -# hotel_id = context.user_data.get("selected_hotel") -# if not hotel_id: -# raise ValueError(f"ID отеля не найден в user_data: {context.user_data}") - -# period = query.data.split("_")[2] -# now = ensure_datetime(datetime.utcnow()) - -# # Получаем диапазон дат -# start_date, end_date = get_period_dates(period, now) - -# reservations = await sync_to_async(list)( -# Reservation.objects.filter( -# hotel_id=hotel_id, -# check_in__gte=start_date, -# check_in__lte=end_date -# ).select_related('hotel') -# ) - -# if not reservations: -# await query.edit_message_text("Нет данных для статистики за выбранный период.") -# return - -# hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) - -# file_path = await generate_pdf_report(hotel.name, reservations, start_date, end_date) - -# with open(file_path, "rb") as file: -# await query.message.reply_document(document=file, filename=f"{hotel.name}_report.pdf") - -# if os.path.exists(file_path): -# os.remove(file_path) - -# except Exception as e: -# logging.error(f"Ошибка в generate_statistics: {str(e)}", exc_info=True) -# logging.error(f'start_date_type: {type(start_date)}, \n end_date_type: {type(end_date)}\n') -# await query.edit_message_text(f"Произошла ошибка: {str(e)}") - async def generate_statistics(update: Update, context: ContextTypes.DEFAULT_TYPE): """Генерация и отправка статистики.""" - - logger = CustomLogger(__name__).get_logger() query = update.callback_query await query.answer() @@ -166,27 +122,17 @@ async def generate_statistics(update: Update, context: ContextTypes.DEFAULT_TYPE hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) - print(f"[DEBUG] start_date: {start_date}, type: {type(start_date)}") - print(f"[DEBUG] end_date: {end_date}, type: {type(end_date)}") + file_path = await generate_pdf_report(hotel.name, reservations, start_date, end_date) - # Генерация PDF-отчета - file_path = await generate_pdf_report( - hotel.name, - reservations, - start_date=start_date.strftime('%Y-%m-%d %H:%M:%S'), - end_date=end_date.strftime('%Y-%m-%d %H:%M:%S') - ) - - # Отправка PDF-файла with open(file_path, "rb") as file: await query.message.reply_document(document=file, filename=f"{hotel.name}_report.pdf") - # Удаление временного файла if os.path.exists(file_path): os.remove(file_path) except Exception as e: - logger.error(f"Ошибка в generate_statistics: {str(e)}", exc_info=True) + logging.error(f"Ошибка в generate_statistics: {str(e)}", exc_info=True) + logging.error(f'start_date_type: {type(start_date)}, \n end_date_type: {type(end_date)}\n') await query.edit_message_text(f"Произошла ошибка: {str(e)}") @@ -235,3 +181,6 @@ async def stats_back(update: Update, context): keyboard.append([InlineKeyboardButton("🏠 Главная", callback_data="main_menu")]) reply_markup = InlineKeyboardMarkup(keyboard) await query.edit_message_text("Выберите отель:", reply_markup=reply_markup) + + + diff --git a/bot/utils/date_utils.py b/bot/utils/date_utils.py new file mode 100644 index 00000000..8305bd27 --- /dev/null +++ b/bot/utils/date_utils.py @@ -0,0 +1,53 @@ +# bot/utils/date_utils.py +from datetime import datetime, timedelta +import pytz + +def ensure_datetime(value): + """ + Приводит значение к объекту datetime с учетом временной зоны. + + :param value: Значение даты (строка или datetime). + :return: Объект datetime. + :raises: TypeError, если передан некорректный тип. + """ + if isinstance(value, str): + try: + # Если строка соответствует формату ISO 8601 + return datetime.fromisoformat(value) + except ValueError: + raise ValueError(f"Некорректный формат даты: {value}") + elif isinstance(value, datetime): + return value + else: + raise TypeError(f"Ожидался тип str или datetime, получено: {type(value)}") + +def get_period_dates(period, now=None): + """ + Возвращает диапазон дат (start_date, end_date) для заданного периода. + + :param period: Период (строка: 'today', 'yesterday', 'last_week', 'last_month'). + :param now: Текущая дата/время (опционально). + :return: Кортеж (start_date, end_date). + :raises: ValueError, если период не поддерживается. + """ + if now is None: + now = datetime.now(pytz.UTC) + + if period == "today": + start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999) + elif period == "yesterday": + start_date = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=999999) + elif period == "last_week": + start_date = (now - timedelta(days=now.weekday() + 7)).replace(hour=0, minute=0, second=0, microsecond=0) + end_date = (start_date + timedelta(days=6)).replace(hour=23, minute=59, second=59, microsecond=999999) + elif period == "last_month": + first_day_of_current_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last_day_of_previous_month = first_day_of_current_month - timedelta(days=1) + start_date = last_day_of_previous_month.replace(day=1) + end_date = last_day_of_previous_month.replace(hour=23, minute=59, second=59, microsecond=999999) + else: + raise ValueError(f"Неподдерживаемый период: {period}") + + return start_date, end_date diff --git a/pms_integration/plugins/realtycalendar_pms.py b/pms_integration/plugins/realtycalendar_pms.py index f9176617..80544dad 100644 --- a/pms_integration/plugins/realtycalendar_pms.py +++ b/pms_integration/plugins/realtycalendar_pms.py @@ -1,129 +1,10 @@ -# import requests -# import hashlib -# import json -# from .base_plugin import BasePMSPlugin -# from datetime import datetime, timedelta -# from asgiref.sync import sync_to_async - - -# class RealtyCalendarPlugin(BasePMSPlugin): -# """Плагин для импорта данных из системы RealtyCalendar -# """ -# def __init__(self, config): -# super().__init__(config) -# self.public_key = config.public_key -# self.private_key = config.private_key -# self.api_url = config.url.rstrip("/") # Убираем лишний `/` в конце URL - -# if not self.public_key or not self.private_key: -# raise ValueError("Публичный или приватный ключ отсутствует для RealtyCalendar") -# def get_default_parser_settings(self): -# """ -# Возвращает настройки по умолчанию для обработки данных. -# """ -# return { -# "date_format": "%Y-%m-%dT%H:%M:%S", -# "timezone": "UTC" -# } -# def _get_sorted_keys(self, obj): -# """ -# Возвращает отсортированный по имени список ключей. -# """ -# return sorted(list(obj.keys())) - -# def _generate_data_string(self, obj): -# """ -# Формирует строку параметров для подписи. -# """ -# sorted_keys = self._get_sorted_keys(obj) -# string = "".join(f"{key}={obj[key]}" for key in sorted_keys) -# return string + self.private_key - -# def _generate_md5(self, string): -# """ -# Генерирует MD5-хеш от строки. -# """ -# return hashlib.md5(string.encode("utf-8")).hexdigest() - -# def _generate_sign(self, data): -# """ -# Генерирует подпись для данных запроса. -# """ -# data_string = self._generate_data_string(data) -# return self._generate_md5(data_string) - -# def fetch_data(self): -# """ -# Выполняет запрос к API RealtyCalendar для получения данных о бронированиях. -# """ -# base_url = f"https://realtycalendar.ru/api/v1/bookings/{self.public_key}/" -# headers = { -# "Accept": "application/json", -# "Content-Type": "application/json", -# } - -# # Определяем даты выборки -# now = datetime.now() -# data = { -# "begin_date": (now - timedelta(days=7)).strftime("%Y-%m-%d"), -# "end_date": now.strftime("%Y-%m-%d"), -# } - -# # Генерация подписи -# data["sign"] = self._generate_sign(data) - -# # Отправляем запрос -# print(f"URL запроса: {base_url}") -# print(f"Заголовки: {headers}") -# print(f"Данные запроса: {data}") - -# response = requests.post(url=base_url, headers=headers, json=data) - -# # Логируем результат -# print(f"Статус ответа: {response.status_code}") -# print(f"Ответ: {response.text}") - -# # Проверяем успешность запроса -# if response.status_code == 200: -# return response.json().get("bookings", []) -# else: -# raise ValueError(f"Ошибка API RealtyCalendar: {response.status_code}, {response.text}") - -# async def _save_to_db(self, data, hotel_id): -# """ -# Сохраняет данные о бронированиях в базу данных. -# """ -# from hotels.models import Reservation, Hotel - -# hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) -# for item in data: -# try: -# reservation, created = await sync_to_async(Reservation.objects.update_or_create)( -# reservation_id=item["id"], -# hotel=hotel, -# defaults={ -# "room_number": item.get("apartment_id", ""), # ID квартиры -# "check_in": datetime.strptime(item["begin_date"], "%Y-%m-%d"), # Дата заезда -# "check_out": datetime.strptime(item["end_date"], "%Y-%m-%d"), # Дата выезда -# "status": item.get("status", ""), # Статус бронирования -# "price": item.get("amount", 0), # Сумма оплаты -# "client_name": item["client"].get("fio", ""), # Имя клиента -# "client_email": item["client"].get("email", ""), # Email клиента -# "client_phone": item["client"].get("phone", ""), # Телефон клиента -# } -# ) -# print(f"{'Создана' if created else 'Обновлена'} запись: {reservation}") -# except Exception as e: -# print(f"Ошибка при сохранении бронирования ID {item['id']}: {e}") - - import requests import hashlib import json from .base_plugin import BasePMSPlugin from datetime import datetime, timedelta from asgiref.sync import sync_to_async - +from math import ceil class RealtyCalendarPlugin(BasePMSPlugin): """Плагин для импорта данных из системы RealtyCalendar @@ -196,6 +77,7 @@ class RealtyCalendarPlugin(BasePMSPlugin): data = { "begin_date": (now - timedelta(days=7)).strftime("%Y-%m-%d"), "end_date": now.strftime("%Y-%m-%d"), + "room_number": "" } print(f"[DEBUG] Даты выборки: {data}") @@ -223,32 +105,53 @@ class RealtyCalendarPlugin(BasePMSPlugin): raise ValueError(f"Ошибка API RealtyCalendar: {response.status_code}, {response.text}") - async def _save_to_db(self, data, hotel_id): + async def _save_to_db(self, data, hotel_id, batch_size=50): """ - Сохраняет данные о бронированиях в базу данных. + Сохраняет данные о бронированиях в базу данных партиями. """ from hotels.models import Reservation, Hotel - hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) - print(f"[DEBUG] Загружен отель: {hotel.name}") + try: + hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) + self.logger.info(f"Загружен отель: {hotel.name}") - for item in data: - print(f"[DEBUG] Обработка бронирования: {item}") - try: - reservation, created = await sync_to_async(Reservation.objects.update_or_create)( - reservation_id=item["id"], - hotel=hotel, - defaults={ - "room_number": item.get("apartment_id", ""), # ID квартиры - "check_in": datetime.strptime(item["begin_date"], "%Y-%m-%d"), # Дата заезда - "check_out": datetime.strptime(item["end_date"], "%Y-%m-%d"), # Дата выезда - "status": item.get("status", ""), # Статус бронирования - "price": item.get("amount", 0), # Сумма оплаты - "client_name": item["client"].get("fio", ""), # Имя клиента - "client_email": item["client"].get("email", ""), # Email клиента - "client_phone": item["client"].get("phone", ""), # Телефон клиента - } - ) - print(f"[DEBUG] {'Создана' if created else 'Обновлена'} запись: {reservation}") - except Exception as e: - print(f"[DEBUG] Ошибка при сохранении бронирования ID {item['id']}: {e}") + # Разделение данных на батчи + total_records = len(data) + batches = [data[i:i + batch_size] for i in range(0, total_records, batch_size)] + self.logger.info(f"Обработка {total_records} записей в {len(batches)} партиях...") + + for batch_index, batch in enumerate(batches): + self.logger.info(f"Обработка партии {batch_index + 1}/{len(batches)}") + + for item in batch: + try: + if item.get("is_delete", False): + self.logger.info(f"Пропущена запись с ID {item.get('id')} (удалена).") + continue + + client_data = item.get("client", {}) + if not item.get("id") or not item.get("begin_date") or not item.get("end_date"): + self.logger.warning(f"Пропущена запись с неполными данными: {item}") + continue + + reservation_defaults = { + "room_number": item.get("apartment_id", ""), + "check_in": datetime.strptime(item["begin_date"], "%Y-%m-%d"), + "check_out": datetime.strptime(item["end_date"], "%Y-%m-%d"), + "status": item.get("status", ""), + "price": item.get("amount", 0), + "client_name": client_data.get("fio", ""), + "client_email": client_data.get("email", ""), + "client_phone": client_data.get("phone", ""), + } + + await sync_to_async(Reservation.objects.update_or_create)( + reservation_id=item["id"], + hotel=hotel, + defaults=reservation_defaults + ) + self.logger.info(f"Сохранена запись для бронирования ID {item['id']}.") + except Exception as e: + self.logger.error(f"Ошибка при обработке бронирования ID {item.get('id', 'неизвестно')}: {e}") + except Exception as e: + self.logger.error(f"Ошибка при обработке данных: {e}") \ No newline at end of file diff --git a/pms_integration/plugins/trevelline_pms.py b/pms_integration/plugins/trevelline_pms.py new file mode 100644 index 00000000..6d1b22a5 --- /dev/null +++ b/pms_integration/plugins/trevelline_pms.py @@ -0,0 +1,116 @@ +import logging +import requests +from datetime import datetime +from .base_plugin import BasePMSPlugin + +class TravelLinePMSPlugin(BasePMSPlugin): + """ + Плагин для интеграции с PMS TravelLine. + """ + BASE_URL = "https://partner.tlintegration.com/api/webpms/v1" + + def __init__(self, pms_config): + """ + Инициализация плагина с конфигурацией PMS. + + :param pms_config: Конфигурация PMS (объект PMSConfiguration). + """ + super().__init__(pms_config) + self.api_key = pms_config.api_key + self.logger = logging.getLogger(self.__class__.__name__) + + def _get_headers(self): + """ + Возвращает заголовки для запросов. + """ + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + def _fetch_data(self): + """ + Получение данных из API TravelLine (поиск бронирований). + + :return: Список номеров бронирований. + """ + url = f"{self.BASE_URL}/bookings" + params = { + "roomId": self.pms_config.room_id, + "modifiedFrom": self.pms_config.modified_from, + "modifiedTo": self.pms_config.modified_to, + "state": self.pms_config.state, + "affectsPeriodFrom": self.pms_config.affects_period_from, + "affectsPeriodTo": self.pms_config.affects_period_to, + } + params = {k: v for k, v in params.items() if v is not None} + + try: + response = requests.get(url, headers=self._get_headers(), params=params) + response.raise_for_status() + self.logger.info("Данные успешно получены из API TravelLine.") + return response.json().get("bookingNumbers", []) + except requests.RequestException as e: + self.logger.error(f"Ошибка при запросе к API TravelLine: {e}") + return [] + + def fetch_data(self): + """ + Обертка для получения данных из API TravelLine с дополнительной обработкой. + + :return: Список номеров бронирований. + """ + return self._fetch_data() + + def get_default_parser_settings(self): + """ + Возвращает настройки парсера по умолчанию. + """ + return { + "field_mapping": { + "reservation_id": "bookingNumber", + "check_in": "actualCheckInDateTime", + "check_out": "actualCheckOutDateTime", + "room_number": "roomId", + "status": "state", + }, + "date_format": "%Y-%m-%dT%H:%M" + } + + def process_data(self, booking_number, room_stay_id, action, actual_date_time): + """ + Обработка данных для заселения или выселения проживания. + + :param booking_number: Номер бронирования. + :param room_stay_id: Идентификатор проживания. + :param action: Действие ("check-in" или "check-out"). + :param actual_date_time: Фактические дата и время. + :return: Ответ API. + """ + if action not in ["check-in", "check-out"]: + raise ValueError("Invalid action. Must be 'check-in' or 'check-out'.") + + url = f"{self.BASE_URL}/bookings/{booking_number}/room-stays/{room_stay_id}/{action}" + payload = { + f"actual{action.capitalize()}DateTime": actual_date_time + } + + try: + response = requests.post(url, headers=self._get_headers(), json=payload) + response.raise_for_status() + self.logger.info(f"Успешно выполнено действие '{action}' для бронирования {booking_number}.") + return response.json() + except requests.RequestException as e: + self.logger.error(f"Ошибка при выполнении действия '{action}': {e}") + return {} + + def validate_plugin(self): + """ + Проверка плагина на соответствие требованиям. + """ + required_methods = ["fetch_data", "get_default_parser_settings", "_fetch_data"] + for method in required_methods: + if not hasattr(self, method): + raise ValueError(f"Плагин {type(self).__name__} не реализует метод {method}.") + self.logger.info(f"Плагин {self.__class__.__name__} успешно прошел валидацию.") + return True diff --git a/touchh/settings.py b/touchh/settings.py index 7d23a356..3a825257 100644 --- a/touchh/settings.py +++ b/touchh/settings.py @@ -103,6 +103,7 @@ DATABASES = { 'PASSWORD': os.getenv('DB_PASSWORD'), # Пароль пользователя 'HOST': os.getenv('DB_HOST', default='0.0.0.0'), # Хост (по умолчанию localhost) 'PORT': os.getenv('DB_PORT', default=3308), # Порт (по умолчанию 3306) + }, }