#!/usr/bin/env python3 """ 📊 УНИВЕРСАЛЬНЫЙ ГЕНЕРАТОР ГОРИЗОНТАЛЬНЫХ ОТЧЁТОВ Создание Excel отчета в горизонтальном формате для любого региона Лист 1: Дашборд с графиками и статистикой Лист 2: Детальная таблица аудита (горизонтальный формат с 18 критериями) Использование: Измените переменные REGION и AUDIT_VERSION под нужный регион """ import psycopg2 from psycopg2.extras import RealDictCursor from urllib.parse import unquote import pandas as pd import openpyxl from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, NamedStyle from openpyxl.chart import BarChart, PieChart, LineChart, Reference from openpyxl.chart.label import DataLabelList from openpyxl.utils.dataframe import dataframe_to_rows from openpyxl.drawing.image import Image from datetime import datetime import json import re def clean_text_for_excel(text): """Очистить текст от недопустимых символов для Excel""" if text is None: return '' text = str(text) # Удаляем управляющие символы (кроме переноса строки и табуляции) text = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]', '', text) return text DB_CONFIG = { 'host': '147.45.189.234', 'port': 5432, 'database': 'default_db', 'user': 'gen_user', 'password': unquote('2~~9_%5EkVsU%3F2%5CS') } # ========== НАСТРОЙКИ РЕГИОНА ========== REGION = 'Орловская область' # Измените на нужный регион AUDIT_VERSION = 'v1.0_with_rkn' # Версия аудита ONLY_ACTIVE = True # Только действующие отели (status_name = 'Действует') # ======================================= def get_region_data(): """Получить данные аудита региона""" conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor) cur = conn.cursor() # Получаем данные аудита с информацией об отелях status_filter = "" if ONLY_ACTIVE: status_filter = "AND hm.status_name = 'Действует'" cur.execute(f""" SELECT har.hotel_id, har.hotel_name, har.region_name, har.website, har.has_website, har.total_score, har.max_score, har.score_percentage, har.audit_date, har.audit_version, har.criteria_results, hm.full_name, hm.website_address, hm.owner_inn, hm.owner_ogrn, hm.addresses, hm.phone, hm.email, hm.website_status, hm.rkn_registry_status, hm.rkn_registry_number, hm.rkn_registry_date, hm.rkn_checked_at, hm.register_record, hm.register_record_date, hm.owner_full_name, hm.owner_ogrn, hm.owner_inn, hm.phone, hm.email, hm.category_name, hm.registry_url, hm.status_name FROM hotel_audit_results har LEFT JOIN hotel_main hm ON hm.id = har.hotel_id WHERE har.region_name = %s AND har.audit_version = %s {status_filter} ORDER BY har.score_percentage DESC """, (REGION, AUDIT_VERSION)) audit_data = cur.fetchall() # Статистика по критериям (анализируем criteria_results) criteria_stats = [] if audit_data: # Собираем статистику по критериям из всех отелей criteria_counts = {} total_hotels = len(audit_data) for hotel in audit_data: if hotel['criteria_results']: criteria = hotel['criteria_results'] for criterion in criteria: name = criterion.get('criterion_name', 'Неизвестно') found = criterion.get('found', False) if name not in criteria_counts: criteria_counts[name] = {'total': 0, 'found': 0} criteria_counts[name]['total'] += 1 if found: criteria_counts[name]['found'] += 1 # Преобразуем в список for name, counts in criteria_counts.items(): percentage = (counts['found'] / counts['total'] * 100) if counts['total'] > 0 else 0 criteria_stats.append({ 'criterion_name': name, 'total_checks': counts['total'], 'found_count': counts['found'], 'percentage': percentage }) # Сортируем по проценту выполнения criteria_stats.sort(key=lambda x: x['percentage'], reverse=True) cur.close() conn.close() return audit_data, criteria_stats def create_dashboard_sheet(workbook, audit_data, criteria_stats): """Создать лист дашборда""" ws = workbook.active # Название листа по региону region_short = REGION.replace('г. ', '').replace('область', 'обл.')[:15] ws.title = f"📊 {region_short}" # Стили header_font = Font(name='Arial', size=14, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid') subheader_font = Font(name='Arial', size=12, bold=True) normal_font = Font(name='Arial', size=10) # Заголовок ws['A1'] = f"🏛️ ДАШБОРД АУДИТА ОТЕЛЕЙ {REGION.upper()}" ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092') ws['A1'].alignment = Alignment(horizontal='center') ws.merge_cells('A1:H1') # Общая статистика ws['A3'] = f"📈 ОБЩАЯ СТАТИСТИКА ПО {REGION.upper()}" ws['A3'].font = subheader_font ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid') # Получаем статистику из реестра (БД) conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor) cur = conn.cursor() # Статистика по региону из реестра status_filter_sql = "AND status_name = 'Действует'" if ONLY_ACTIVE else "" cur.execute(f""" SELECT COUNT(*) as total_in_registry, COUNT(CASE WHEN status_name = 'Действует' THEN 1 END) as active_hotels, COUNT(CASE WHEN website_address IS NOT NULL AND website_address != '' THEN 1 END) as with_websites, COUNT(CASE WHEN website_status = 'accessible' THEN 1 END) as accessible_websites FROM hotel_main WHERE region_name = %s {status_filter_sql} """, (REGION,)) registry_stats = cur.fetchone() cur.close() conn.close() # Подсчитываем статистику из аудита total_hotels = len(audit_data) total_with_website = sum(1 for h in audit_data if h['has_website']) total_without_website = total_hotels - total_with_website total_in_rkn = sum(1 for h in audit_data if h.get('rkn_registry_number')) total_compliant = sum(1 for h in audit_data if h['score_percentage'] >= 50) avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0 # Выводим детальную статистику row = 4 ws[f'A{row}'] = f"📋 По данным реестра в {REGION}:" ws[f'A{row}'].font = Font(name='Arial', size=10, bold=True) row += 1 if ONLY_ACTIVE: ws[f'A{row}'] = f" • Всего действующих отелей: {registry_stats['total_in_registry']}" else: ws[f'A{row}'] = f" • Всего отелей в реестре: {registry_stats['total_in_registry']}" row += 1 ws[f'A{row}'] = f" • Из них действующих: {registry_stats['active_hotels']}" row += 1 ws[f'A{row}'] = f" • Отелей с указанными сайтами: {registry_stats['with_websites']}" row += 1 ws[f'A{row}'] = f" • Доступных сайтов (на момент проверки): {registry_stats['accessible_websites']}" row += 2 ws[f'A{row}'] = f"🔍 Проведено аудитов: {total_hotels}" ws[f'A{row}'].font = Font(name='Arial', size=10, bold=True) row += 1 ws[f'A{row}'] = f" • С сайтами: {total_with_website}" row += 1 ws[f'A{row}'] = f" • Без сайтов: {total_without_website}" row += 1 ws[f'A{row}'] = f" • В реестре РКН: {total_in_rkn}" row += 1 ws[f'A{row}'] = f" • Средний балл соответствия: {avg_score:.1f}%" row += 1 ws[f'A{row}'] = f" • Отелей с баллом ≥50%: {total_compliant}" # Форматирование for r in range(4, row + 1): ws[f'A{r}'].font = normal_font # Статистика по критериям (сдвигаем вниз) criteria_start_row = row + 3 ws[f'A{criteria_start_row}'] = "🎯 СТАТИСТИКА ПО КРИТЕРИЯМ" ws[f'A{criteria_start_row}'].font = subheader_font ws[f'A{criteria_start_row}'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid') # Заголовки таблицы критериев criteria_headers = ['Критерий', 'Найдено', 'Не найдено'] header_row = criteria_start_row + 1 for i, header in enumerate(criteria_headers, 1): cell = ws.cell(row=header_row, column=i, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal='center') # Данные по критериям data_start_row = header_row + 1 for idx, criterion in enumerate(criteria_stats): current_row = data_start_row + idx not_found = criterion['total_checks'] - criterion['found_count'] ws.cell(row=current_row, column=1, value=criterion['criterion_name']) ws.cell(row=current_row, column=2, value=criterion['found_count']) ws.cell(row=current_row, column=3, value=not_found) # Форматирование for col in range(1, 4): ws.cell(row=current_row, column=col).font = normal_font ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center') # Распределение по баллам scores_start_row = data_start_row + len(criteria_stats) + 2 ws[f'A{scores_start_row}'] = "📊 РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ" ws[f'A{scores_start_row}'].font = subheader_font ws[f'A{scores_start_row}'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid') # Заголовки score_headers = ['Диапазон', 'Количество'] score_header_row = scores_start_row + 1 for i, header in enumerate(score_headers, 1): cell = ws.cell(row=score_header_row, column=i, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal='center') # Данные по баллам score_ranges = [ ('0-25%', sum(1 for h in audit_data if h['score_percentage'] < 26)), ('26-50%', sum(1 for h in audit_data if 26 <= h['score_percentage'] < 51)), ('51-75%', sum(1 for h in audit_data if 51 <= h['score_percentage'] < 76)), ('76-100%', sum(1 for h in audit_data if h['score_percentage'] >= 76)) ] score_data_start = score_header_row + 1 for idx, (range_name, count) in enumerate(score_ranges): current_row = score_data_start + idx ws.cell(row=current_row, column=1, value=range_name) ws.cell(row=current_row, column=2, value=count) # Форматирование for col in range(1, 3): ws.cell(row=current_row, column=col).font = normal_font ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center') # Графики # Круговой график статуса сайтов pie_chart = PieChart() pie_chart.title = "Статус сайтов отелей" # Данные для пирога: Сайты доступны (4), Сайты недоступны (0), Без сайтов (8), В реестре РКН (10) pie_data = Reference(ws, min_col=2, min_row=14, max_row=17, max_col=2) pie_labels = Reference(ws, min_col=1, min_row=14, max_row=17, max_col=1) pie_chart.add_data(pie_data, titles_from_data=False) pie_chart.set_categories(pie_labels) pie_chart.height = 10 pie_chart.width = 15 # Добавляем подписи данных pie_chart.dataLabels = DataLabelList() pie_chart.dataLabels.showPercent = True pie_chart.dataLabels.showCategoryName = True ws.add_chart(pie_chart, "C3") # Столбчатый график по критериям chart1 = BarChart() chart1.title = "Результаты по критериям" chart1.x_axis.title = "Критерии" chart1.y_axis.title = "Количество отелей" data = Reference(ws, min_col=2, min_row=20, max_row=20+len(criteria_stats), max_col=3) cats = Reference(ws, min_col=1, min_row=21, max_row=20+len(criteria_stats)) chart1.add_data(data, titles_from_data=False) chart1.set_categories(cats) chart1.height = 10 chart1.width = 20 ws.add_chart(chart1, "C20") # График распределения по баллам chart2 = BarChart() chart2.title = "Распределение по баллам" chart2.x_axis.title = "Диапазон баллов" chart2.y_axis.title = "Количество отелей" data2 = Reference(ws, min_col=2, min_row=41, max_row=41+len(score_ranges), max_col=2) cats2 = Reference(ws, min_col=1, min_row=42, max_row=41+len(score_ranges)) chart2.add_data(data2, titles_from_data=False) chart2.set_categories(cats2) chart2.height = 8 chart2.width = 12 ws.add_chart(chart2, "C40") # Настройка ширины колонок column_widths = [30, 10, 10] for i, width in enumerate(column_widths, 1): ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width def create_audit_sheet(workbook, audit_data): """Создать лист детального аудита в горизонтальном формате""" ws = workbook.create_sheet("🏨 Аудит отелей") # Стили header_font = Font(name='Arial', size=10, bold=True, color='FFFFFF') header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid') normal_font = Font(name='Arial', size=8) # Базовые заголовки base_headers = [ 'Отель', 'Дата включения в реестр', 'Владелец', 'ОГРН', 'ИНН', 'Электронная почта владельца', 'Телефон владельца', 'Электронная почта средства размещения', 'Телефон средства размещения', 'Категория объекта', 'Ссылка на запись в реестре', 'Сайт', 'Есть сайт', 'Балл', 'Процент' ] # Заголовки критериев (по 3 колонки на каждый) criteria_headers = [] criteria_names = [ "1. Юридическая идентификация и верификация", "2. Адрес", "3. Контакты", "4. Режим работы", "5. Политика ПДн (152-ФЗ)", "6. РКН Реестр", "7. Договор-оферта / Правила оказания услуг", "8. Рекламации и споры", "9. Цены/прайс", "10. Способы оплаты", "11. Онлайн-оплата", "12. Онлайн-бронирование", "13. FAQ", "14. Доступность для ЛОВЗ", "15. Партнеры/бренды", "16. Команда/сотрудники", "17. Уголок потребителя", "18. Актуальность документов" ] for criterion in criteria_names: criteria_headers.extend([criterion, f"{criterion} URL", f"{criterion} Комментарий"]) # Все заголовки all_headers = base_headers + criteria_headers # Записываем заголовки for i, header in enumerate(all_headers, 1): cell = ws.cell(row=1, column=i, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) # Данные по отелям for i, hotel in enumerate(audit_data, 2): # Базовые данные ws.cell(row=i, column=1, value=hotel['hotel_name'] or hotel['full_name']) # Данные реестра ws.cell(row=i, column=2, value=hotel.get('register_record_date', '')) # Дата включения в реестр ws.cell(row=i, column=3, value=hotel.get('owner_full_name', '')) # Владелец ws.cell(row=i, column=4, value=hotel.get('owner_ogrn', '')) # ОГРН ws.cell(row=i, column=5, value=hotel.get('owner_inn', '')) # ИНН ws.cell(row=i, column=6, value=hotel.get('email', '')) # Email владельца ws.cell(row=i, column=7, value=hotel.get('phone', '')) # Телефон владельца ws.cell(row=i, column=8, value=hotel.get('email', '')) # Email средства размещения (тот же) ws.cell(row=i, column=9, value=hotel.get('phone', '')) # Телефон средства размещения (тот же) ws.cell(row=i, column=10, value=hotel.get('category_name', '')) # Категория объекта # Ссылка на запись в реестре (используем готовую ссылку из БД) registry_link = hotel.get('registry_url', '') ws.cell(row=i, column=11, value=registry_link) # Основные данные ws.cell(row=i, column=12, value=hotel['website'] or hotel['website_address']) # Сайт ws.cell(row=i, column=13, value='Да' if hotel['has_website'] else 'Нет') # Есть сайт ws.cell(row=i, column=14, value=f"{hotel['total_score']}/{hotel['max_score']}") # Балл ws.cell(row=i, column=15, value=f"{hotel['score_percentage']:.1f}%") # Процент # Цветовое кодирование процента percentage = hotel['score_percentage'] or 0 if percentage >= 50: fill_color = 'C6EFCE' # Зеленый elif percentage >= 30: fill_color = 'FFEB9C' # Желтый else: fill_color = 'FFC7CE' # Красный ws.cell(row=i, column=15).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid') # Данные по критериям col_idx = 16 # Начинаем с 16-й колонки (сдвинули на 10 вправо) if hotel['criteria_results']: criteria = hotel['criteria_results'] for criterion in criteria: criterion_id = criterion.get('criterion_id') # Статус status = 'Да' if criterion.get('found', False) else 'Нет' ws.cell(row=i, column=col_idx, value=status) # URL и Комментарий - специальная обработка для критерия 6 (РКН) if criterion_id == 6: # РКН Реестр # URL на реестр РКН rkn_number = hotel.get('rkn_registry_number', '') if rkn_number: url = f"https://rkn.gov.ru/mass-communications/reestr/search/?q={rkn_number}" else: url = "" ws.cell(row=i, column=col_idx + 1, value=url) # Комментарий: номер РКН + дата rkn_date = hotel.get('rkn_registry_date', '') if rkn_number and rkn_date: comment = f"{rkn_number}\n{rkn_date}" elif rkn_number: comment = rkn_number else: comment = "" ws.cell(row=i, column=col_idx + 2, value=comment) else: # Обычная обработка для остальных критериев url = criterion.get('ai_agent', {}).get('url', '') ws.cell(row=i, column=col_idx + 1, value=clean_text_for_excel(url)) # Используем details вместо quote для коротких комментариев comment = criterion.get('ai_agent', {}).get('details', '') if not comment: # Если details нет, используем quote но обрезаем comment = criterion.get('ai_agent', {}).get('quote', '') if len(comment) > 100: comment = comment[:100] + '...' ws.cell(row=i, column=col_idx + 2, value=clean_text_for_excel(comment)) # Цветовое кодирование статуса if criterion.get('found', False): fill_color = 'C6EFCE' # Зеленый else: fill_color = 'FFC7CE' # Красный ws.cell(row=i, column=col_idx).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid') col_idx += 3 # Форматирование всех ячеек строки for col in range(1, len(all_headers) + 1): cell = ws.cell(row=i, column=col) cell.font = normal_font cell.alignment = Alignment(horizontal='center', vertical='top', wrap_text=True) # Автоподбор ширины колонок for column in ws.columns: max_length = 0 column_letter = column[0].column_letter for cell in column: try: if cell.value: # Учитываем переносы строк lines = str(cell.value).split('\n') max_line_length = max(len(line) for line in lines) if lines else 0 max_length = max(max_length, max_line_length) except: pass # Ограничиваем ширину adjusted_width = min(max_length + 2, 50) # Максимум 50 adjusted_width = max(adjusted_width, 10) # Минимум 10 ws.column_dimensions[column_letter].width = adjusted_width # Фильтры ws.auto_filter.ref = f"A1:{openpyxl.utils.get_column_letter(len(all_headers))}{len(audit_data)+1}" # Заморозка заголовков ws.freeze_panes = "A2" def main(): """Основная функция""" print(f"🏛️ СОЗДАНИЕ ОТЧЕТА ПО {REGION.upper()} (ГОРИЗОНТАЛЬНЫЙ ФОРМАТ)") print("=" * 60) # Получаем данные print("📊 Загружаем данные из БД...") audit_data, criteria_stats = get_region_data() print(f"✅ Загружено:") print(f" 🏨 Отелей: {len(audit_data)}") print(f" 🎯 Критериев: {len(criteria_stats)}") # Создаем Excel файл print("\n📝 Создаем Excel файл...") workbook = Workbook() # Лист дашборда print("📊 Создаем дашборд...") create_dashboard_sheet(workbook, audit_data, criteria_stats) # Лист аудита print("🏨 Создаем таблицу аудита (горизонтальный формат)...") create_audit_sheet(workbook, audit_data) # Сохраняем файл timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"experimental_report_{timestamp}.xlsx" workbook.save(filename) print(f"\n✅ Отчет сохранен: {filename}") print(f"📊 Листы:") print(f" 📈 Дашборд СПб - графики и статистика") print(f" 🏨 Аудит отелей - горизонтальный формат (как в CSV)") if __name__ == "__main__": main()