2025-10-16 10:52:09 +03:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2025-10-27 22:49:42 +03:00
|
|
|
|
📊 УНИВЕРСАЛЬНЫЙ ГЕНЕРАТОР ГОРИЗОНТАЛЬНЫХ ОТЧЁТОВ
|
|
|
|
|
|
Создание Excel отчета в горизонтальном формате для любого региона
|
2025-10-16 10:52:09 +03:00
|
|
|
|
Лист 1: Дашборд с графиками и статистикой
|
2025-10-27 22:49:42 +03:00
|
|
|
|
Лист 2: Детальная таблица аудита (горизонтальный формат с 18 критериями)
|
|
|
|
|
|
|
|
|
|
|
|
Использование:
|
|
|
|
|
|
Измените переменные REGION и AUDIT_VERSION под нужный регион
|
2025-10-16 10:52:09 +03:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2025-10-27 22:49:42 +03:00
|
|
|
|
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
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
DB_CONFIG = {
|
|
|
|
|
|
'host': '147.45.189.234',
|
|
|
|
|
|
'port': 5432,
|
|
|
|
|
|
'database': 'default_db',
|
|
|
|
|
|
'user': 'gen_user',
|
|
|
|
|
|
'password': unquote('2~~9_%5EkVsU%3F2%5CS')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-27 22:49:42 +03:00
|
|
|
|
# ========== НАСТРОЙКИ РЕГИОНА ==========
|
2025-10-27 23:16:29 +03:00
|
|
|
|
REGION = 'Орловская область' # Измените на нужный регион
|
2025-10-27 22:49:42 +03:00
|
|
|
|
AUDIT_VERSION = 'v1.0_with_rkn' # Версия аудита
|
2025-10-27 23:16:29 +03:00
|
|
|
|
ONLY_ACTIVE = True # Только действующие отели (status_name = 'Действует')
|
2025-10-27 22:49:42 +03:00
|
|
|
|
# =======================================
|
|
|
|
|
|
|
|
|
|
|
|
def get_region_data():
|
|
|
|
|
|
"""Получить данные аудита региона"""
|
2025-10-16 10:52:09 +03:00
|
|
|
|
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
|
|
|
|
|
|
cur = conn.cursor()
|
|
|
|
|
|
|
2025-10-27 22:49:42 +03:00
|
|
|
|
# Получаем данные аудита с информацией об отелях
|
2025-10-27 23:16:29 +03:00
|
|
|
|
status_filter = ""
|
|
|
|
|
|
if ONLY_ACTIVE:
|
|
|
|
|
|
status_filter = "AND hm.status_name = 'Действует'"
|
|
|
|
|
|
|
|
|
|
|
|
cur.execute(f"""
|
2025-10-16 10:52:09 +03:00
|
|
|
|
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,
|
2025-10-27 22:49:42 +03:00
|
|
|
|
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,
|
2025-10-27 23:16:29 +03:00
|
|
|
|
hm.registry_url,
|
|
|
|
|
|
hm.status_name
|
2025-10-16 10:52:09 +03:00
|
|
|
|
FROM hotel_audit_results har
|
|
|
|
|
|
LEFT JOIN hotel_main hm ON hm.id = har.hotel_id
|
2025-10-27 22:49:42 +03:00
|
|
|
|
WHERE har.region_name = %s
|
|
|
|
|
|
AND har.audit_version = %s
|
2025-10-27 23:16:29 +03:00
|
|
|
|
{status_filter}
|
2025-10-16 10:52:09 +03:00
|
|
|
|
ORDER BY har.score_percentage DESC
|
2025-10-27 22:49:42 +03:00
|
|
|
|
""", (REGION, AUDIT_VERSION))
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws.title = "📊 Дашборд СПб"
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Стили
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# Заголовок
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws['A1'] = f"🏛️ ДАШБОРД АУДИТА ОТЕЛЕЙ {REGION.upper()}"
|
2025-10-16 10:52:09 +03:00
|
|
|
|
ws['A1'].font = Font(name='Arial', size=16, bold=True, color='366092')
|
|
|
|
|
|
ws['A1'].alignment = Alignment(horizontal='center')
|
|
|
|
|
|
ws.merge_cells('A1:H1')
|
|
|
|
|
|
|
|
|
|
|
|
# Общая статистика
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws['A3'] = f"📈 ОБЩАЯ СТАТИСТИКА ПО {REGION.upper()}"
|
2025-10-16 10:52:09 +03:00
|
|
|
|
ws['A3'].font = subheader_font
|
|
|
|
|
|
ws['A3'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
|
|
|
|
|
|
|
|
|
|
|
|
# Подсчитываем статистику
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws['A4'] = f"Всего отелей в {REGION}: {total_hotels}"
|
2025-10-16 10:52:09 +03:00
|
|
|
|
ws['A5'] = f"С сайтами: {total_with_website}"
|
|
|
|
|
|
ws['A6'] = f"Без сайтов: {total_without_website}"
|
|
|
|
|
|
ws['A7'] = f"Сайты доступны для анализа: {total_with_website}"
|
|
|
|
|
|
ws['A8'] = f"Сайты недоступны: 0"
|
|
|
|
|
|
ws['A9'] = f"В реестре РКН: {total_in_rkn}"
|
|
|
|
|
|
ws['A10'] = f"Проведено аудитов: {total_hotels}"
|
|
|
|
|
|
ws['A11'] = f"Средний балл (аудит): {avg_score:.1f}%"
|
|
|
|
|
|
|
|
|
|
|
|
for cell in ['A4', 'A5', 'A6', 'A7', 'A8', 'A9', 'A10', 'A11']:
|
|
|
|
|
|
ws[cell].font = normal_font
|
|
|
|
|
|
|
|
|
|
|
|
# Категория
|
|
|
|
|
|
ws['A13'] = "Категория"
|
|
|
|
|
|
ws['A13'].font = subheader_font
|
|
|
|
|
|
ws['A13'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
|
|
|
|
|
|
|
|
|
|
|
|
ws['A14'] = f"Сайты доступны: {total_with_website}"
|
|
|
|
|
|
ws['B14'] = total_with_website
|
|
|
|
|
|
ws['A15'] = f"Сайты недоступны: 0"
|
|
|
|
|
|
ws['B15'] = 0
|
|
|
|
|
|
ws['A16'] = f"Без сайтов: {total_without_website}"
|
|
|
|
|
|
ws['B16'] = total_without_website
|
|
|
|
|
|
ws['A17'] = f"В реестре РКН: {total_in_rkn}"
|
|
|
|
|
|
ws['B17'] = total_in_rkn
|
|
|
|
|
|
|
|
|
|
|
|
for cell in ['A14', 'A15', 'A16', 'A17']:
|
|
|
|
|
|
ws[cell].font = normal_font
|
|
|
|
|
|
|
|
|
|
|
|
# Статистика по критериям
|
|
|
|
|
|
ws['A19'] = "🎯 СТАТИСТИКА ПО КРИТЕРИЯМ"
|
|
|
|
|
|
ws['A19'].font = subheader_font
|
|
|
|
|
|
ws['A19'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
|
|
|
|
|
|
|
|
|
|
|
|
# Заголовки таблицы критериев
|
|
|
|
|
|
criteria_headers = ['Критерий', 'Найдено', 'Не найдено']
|
|
|
|
|
|
for i, header in enumerate(criteria_headers, 1):
|
|
|
|
|
|
cell = ws.cell(row=20, column=i, value=header)
|
|
|
|
|
|
cell.font = header_font
|
|
|
|
|
|
cell.fill = header_fill
|
|
|
|
|
|
cell.alignment = Alignment(horizontal='center')
|
|
|
|
|
|
|
|
|
|
|
|
# Данные по критериям
|
|
|
|
|
|
for i, criterion in enumerate(criteria_stats, 21):
|
|
|
|
|
|
not_found = criterion['total_checks'] - criterion['found_count']
|
|
|
|
|
|
|
|
|
|
|
|
ws.cell(row=i, column=1, value=criterion['criterion_name'])
|
|
|
|
|
|
ws.cell(row=i, column=2, value=criterion['found_count'])
|
|
|
|
|
|
ws.cell(row=i, column=3, value=not_found)
|
|
|
|
|
|
|
|
|
|
|
|
# Форматирование
|
|
|
|
|
|
for col in range(1, 4):
|
|
|
|
|
|
ws.cell(row=i, column=col).font = normal_font
|
|
|
|
|
|
ws.cell(row=i, column=col).alignment = Alignment(horizontal='center')
|
|
|
|
|
|
|
|
|
|
|
|
# Распределение по баллам
|
|
|
|
|
|
ws['A40'] = "📊 РАСПРЕДЕЛЕНИЕ ПО БАЛЛАМ"
|
|
|
|
|
|
ws['A40'].font = subheader_font
|
|
|
|
|
|
ws['A40'].fill = PatternFill(start_color='E7E6E6', end_color='E7E6E6', fill_type='solid')
|
|
|
|
|
|
|
|
|
|
|
|
# Заголовки
|
|
|
|
|
|
score_headers = ['Диапазон', 'Количество']
|
|
|
|
|
|
for i, header in enumerate(score_headers, 1):
|
|
|
|
|
|
cell = ws.cell(row=41, 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))
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for i, (range_name, count) in enumerate(score_ranges, 42):
|
|
|
|
|
|
ws.cell(row=i, column=1, value=range_name)
|
|
|
|
|
|
ws.cell(row=i, column=2, value=count)
|
|
|
|
|
|
|
|
|
|
|
|
# Форматирование
|
|
|
|
|
|
for col in range(1, 3):
|
|
|
|
|
|
ws.cell(row=i, column=col).font = normal_font
|
|
|
|
|
|
ws.cell(row=i, 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)
|
|
|
|
|
|
|
|
|
|
|
|
# Базовые заголовки
|
2025-10-27 22:49:42 +03:00
|
|
|
|
base_headers = [
|
|
|
|
|
|
'Отель',
|
|
|
|
|
|
'Дата включения в реестр',
|
|
|
|
|
|
'Владелец',
|
|
|
|
|
|
'ОГРН',
|
|
|
|
|
|
'ИНН',
|
|
|
|
|
|
'Электронная почта владельца',
|
|
|
|
|
|
'Телефон владельца',
|
|
|
|
|
|
'Электронная почта средства размещения',
|
|
|
|
|
|
'Телефон средства размещения',
|
|
|
|
|
|
'Категория объекта',
|
|
|
|
|
|
'Ссылка на запись в реестре',
|
|
|
|
|
|
'Сайт',
|
|
|
|
|
|
'Есть сайт',
|
|
|
|
|
|
'Балл',
|
|
|
|
|
|
'Процент'
|
|
|
|
|
|
]
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Заголовки критериев (по 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'])
|
2025-10-27 22:49:42 +03:00
|
|
|
|
|
|
|
|
|
|
# Данные реестра
|
|
|
|
|
|
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}%") # Процент
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Цветовое кодирование процента
|
|
|
|
|
|
percentage = hotel['score_percentage'] or 0
|
|
|
|
|
|
if percentage >= 50:
|
|
|
|
|
|
fill_color = 'C6EFCE' # Зеленый
|
|
|
|
|
|
elif percentage >= 30:
|
|
|
|
|
|
fill_color = 'FFEB9C' # Желтый
|
|
|
|
|
|
else:
|
|
|
|
|
|
fill_color = 'FFC7CE' # Красный
|
|
|
|
|
|
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws.cell(row=i, column=15).fill = PatternFill(start_color=fill_color, end_color=fill_color, fill_type='solid')
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Данные по критериям
|
2025-10-27 22:49:42 +03:00
|
|
|
|
col_idx = 16 # Начинаем с 16-й колонки (сдвинули на 10 вправо)
|
2025-10-16 10:52:09 +03:00
|
|
|
|
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', '')
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws.cell(row=i, column=col_idx + 1, value=clean_text_for_excel(url))
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Используем 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] + '...'
|
2025-10-27 22:49:42 +03:00
|
|
|
|
ws.cell(row=i, column=col_idx + 2, value=clean_text_for_excel(comment))
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
# Цветовое кодирование статуса
|
|
|
|
|
|
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():
|
|
|
|
|
|
"""Основная функция"""
|
2025-10-27 22:49:42 +03:00
|
|
|
|
print(f"🏛️ СОЗДАНИЕ ОТЧЕТА ПО {REGION.upper()} (ГОРИЗОНТАЛЬНЫЙ ФОРМАТ)")
|
2025-10-16 10:52:09 +03:00
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
|
|
|
|
|
|
# Получаем данные
|
|
|
|
|
|
print("📊 Загружаем данные из БД...")
|
2025-10-27 22:49:42 +03:00
|
|
|
|
audit_data, criteria_stats = get_region_data()
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
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")
|
2025-10-27 22:49:42 +03:00
|
|
|
|
filename = f"experimental_report_{timestamp}.xlsx"
|
2025-10-16 10:52:09 +03:00
|
|
|
|
|
|
|
|
|
|
workbook.save(filename)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n✅ Отчет сохранен: {filename}")
|
|
|
|
|
|
print(f"📊 Листы:")
|
2025-10-27 22:49:42 +03:00
|
|
|
|
print(f" 📈 Дашборд СПб - графики и статистика")
|
2025-10-16 10:52:09 +03:00
|
|
|
|
print(f" 🏨 Аудит отелей - горизонтальный формат (как в CSV)")
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|