- Создан create_pdf_report.py для генерации PDF отчётов - Поддержка кириллицы через DejaVu Sans шрифты - 3 типа графиков: распределение по баллам, топ-10 критериев, общая статистика - Отчёт для Орловской области: 259KB, 5 страниц - Обновлен create_horizontal_report.py
430 lines
18 KiB
Python
430 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
📄 Генератор PDF отчета с графиками и заключением
|
||
Создает красивый PDF документ с результатами аудита отелей Орловской области
|
||
"""
|
||
|
||
import psycopg2
|
||
from psycopg2.extras import RealDictCursor
|
||
from urllib.parse import unquote
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib
|
||
matplotlib.use('Agg') # Для работы без GUI
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib.units import inch
|
||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Image
|
||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
|
||
from reportlab.lib.colors import HexColor
|
||
from reportlab.pdfgen import canvas
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
from datetime import datetime
|
||
import os
|
||
|
||
# Регистрируем шрифты с поддержкой кириллицы
|
||
pdfmetrics.registerFont(TTFont('DejaVuSans', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'))
|
||
pdfmetrics.registerFont(TTFont('DejaVuSans-Bold', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'))
|
||
pdfmetrics.registerFont(TTFont('DejaVuSans-Oblique', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf'))
|
||
pdfmetrics.registerFont(TTFont('DejaVuSerif', '/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf'))
|
||
pdfmetrics.registerFont(TTFont('DejaVuSerif-Bold', '/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf'))
|
||
|
||
# Абсолютный путь для временных файлов
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
# Настройка БД
|
||
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'
|
||
|
||
# Стили для PDF
|
||
styles = getSampleStyleSheet()
|
||
|
||
# Создаем кастомные стили
|
||
title_style = ParagraphStyle(
|
||
'CustomTitle',
|
||
parent=styles['Heading1'],
|
||
fontSize=20,
|
||
textColor=HexColor('#1f4788'),
|
||
spaceAfter=30,
|
||
alignment=TA_CENTER,
|
||
fontName='DejaVuSans-Bold'
|
||
)
|
||
|
||
heading_style = ParagraphStyle(
|
||
'CustomHeading',
|
||
parent=styles['Heading2'],
|
||
fontSize=14,
|
||
textColor=HexColor('#2c5aa0'),
|
||
spaceAfter=15,
|
||
spaceBefore=20,
|
||
fontName='DejaVuSans-Bold'
|
||
)
|
||
|
||
subheading_style = ParagraphStyle(
|
||
'CustomSubheading',
|
||
parent=styles['Heading3'],
|
||
fontSize=12,
|
||
textColor=HexColor('#4a90e2'),
|
||
spaceAfter=10,
|
||
spaceBefore=15,
|
||
fontName='DejaVuSans-Bold'
|
||
)
|
||
|
||
normal_style = ParagraphStyle(
|
||
'CustomNormal',
|
||
parent=styles['Normal'],
|
||
fontSize=11,
|
||
textColor=HexColor('#333333'),
|
||
alignment=TA_JUSTIFY,
|
||
spaceAfter=10,
|
||
leading=14,
|
||
fontName='DejaVuSans'
|
||
)
|
||
|
||
highlight_style = ParagraphStyle(
|
||
'CustomHighlight',
|
||
parent=normal_style,
|
||
textColor=HexColor('#1f4788'),
|
||
fontSize=12,
|
||
fontName='DejaVuSans-Bold'
|
||
)
|
||
|
||
def get_database_stats():
|
||
"""Получить статистику из БД"""
|
||
conn = psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor)
|
||
cur = conn.cursor()
|
||
|
||
# Статистика из реестра
|
||
cur.execute("""
|
||
SELECT
|
||
COUNT(*) as total_in_registry,
|
||
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 AND status_name = 'Действует'
|
||
""", (REGION,))
|
||
registry_stats = cur.fetchone()
|
||
|
||
# Данные аудита
|
||
cur.execute("""
|
||
SELECT
|
||
har.score_percentage,
|
||
har.has_website,
|
||
har.criteria_results
|
||
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 AND hm.status_name = 'Действует'
|
||
""", (REGION, AUDIT_VERSION))
|
||
audit_data = cur.fetchall()
|
||
|
||
# Анализ критериев
|
||
criteria_stats = {}
|
||
for hotel in audit_data:
|
||
if hotel['criteria_results']:
|
||
for criterion in hotel['criteria_results']:
|
||
name = criterion.get('criterion_name', 'Неизвестно')
|
||
found = criterion.get('found', False)
|
||
|
||
if name not in criteria_stats:
|
||
criteria_stats[name] = {'total': 0, 'found': 0}
|
||
|
||
criteria_stats[name]['total'] += 1
|
||
if found:
|
||
criteria_stats[name]['found'] += 1
|
||
|
||
cur.close()
|
||
conn.close()
|
||
|
||
return registry_stats, audit_data, criteria_stats
|
||
|
||
def create_score_distribution_chart(audit_data, filename):
|
||
"""Создать график распределения по баллам"""
|
||
scores = [h['score_percentage'] for h in audit_data]
|
||
|
||
# Категории
|
||
ranges = ['0-25%', '26-50%', '51-75%', '76-100%']
|
||
counts = [
|
||
sum(1 for s in scores if 0 <= s < 26),
|
||
sum(1 for s in scores if 26 <= s < 51),
|
||
sum(1 for s in scores if 51 <= s < 76),
|
||
sum(1 for s in scores if 76 <= s <= 100)
|
||
]
|
||
|
||
# Создаем pie chart
|
||
fig, ax = plt.subplots(figsize=(10, 8))
|
||
colors = ['#ff6b6b', '#ffa726', '#66bb6a', '#42a5f5']
|
||
wedges, texts, autotexts = ax.pie(
|
||
counts,
|
||
labels=ranges,
|
||
autopct='%1.1f%%',
|
||
colors=colors,
|
||
startangle=90,
|
||
textprops={'fontsize': 12, 'fontweight': 'bold'}
|
||
)
|
||
|
||
ax.set_title('Распределение отелей по баллам соответствия',
|
||
fontsize=14, fontweight='bold', pad=20)
|
||
|
||
plt.tight_layout()
|
||
full_path = os.path.join(SCRIPT_DIR, filename)
|
||
plt.savefig(full_path, dpi=150, bbox_inches='tight')
|
||
plt.close()
|
||
print(f"✅ График 1 сохранен: {full_path}")
|
||
|
||
def create_criteria_chart(criteria_stats, filename):
|
||
"""Создать график по критериям (топ-10)"""
|
||
sorted_criteria = sorted(criteria_stats.items(),
|
||
key=lambda x: x[1]['found']/x[1]['total'] if x[1]['total'] > 0 else 0,
|
||
reverse=True)
|
||
|
||
top_criteria = sorted_criteria[:10]
|
||
|
||
criteria_names = [name[:40] + '...' if len(name) > 40 else name
|
||
for name, _ in top_criteria]
|
||
found_counts = [stats['found'] for _, stats in top_criteria]
|
||
not_found_counts = [stats['total'] - stats['found'] for _, stats in top_criteria]
|
||
|
||
fig, ax = plt.subplots(figsize=(14, 8))
|
||
|
||
x = range(len(criteria_names))
|
||
width = 0.6
|
||
|
||
bars1 = ax.barh(x, found_counts, width, label='Найдено', color='#66bb6a')
|
||
bars2 = ax.barh(x, not_found_counts, width, left=found_counts,
|
||
label='Не найдено', color='#ff6b6b')
|
||
|
||
ax.set_yticks(x)
|
||
ax.set_yticklabels(criteria_names, fontsize=9)
|
||
ax.set_xlabel('Количество отелей', fontweight='bold', fontsize=11)
|
||
ax.set_title('Топ-10 критериев: выполнение/невыполнение',
|
||
fontsize=14, fontweight='bold', pad=20)
|
||
ax.legend(loc='lower right', fontsize=10)
|
||
ax.grid(axis='x', alpha=0.3)
|
||
|
||
plt.tight_layout()
|
||
full_path = os.path.join(SCRIPT_DIR, filename)
|
||
plt.savefig(full_path, dpi=150, bbox_inches='tight')
|
||
plt.close()
|
||
print(f"✅ График 2 сохранен: {full_path}")
|
||
|
||
def create_summary_chart(registry_stats, audit_data, filename):
|
||
"""Создать сводный график по статистике"""
|
||
# Подготовка данных
|
||
total_hotels = len(audit_data)
|
||
avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0
|
||
|
||
categories = ['Всего в реестре', 'С сайтами', 'Проведено аудитов', 'Соответствие\n(≥50%)']
|
||
values = [
|
||
registry_stats['total_in_registry'],
|
||
registry_stats['with_websites'],
|
||
total_hotels,
|
||
sum(1 for h in audit_data if h['score_percentage'] >= 50)
|
||
]
|
||
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
bars = ax.bar(categories, values, color=['#4a90e2', '#66bb6a', '#ffa726', '#42a5f5'])
|
||
|
||
ax.set_title('Общая статистика аудита', fontsize=14, fontweight='bold', pad=20)
|
||
ax.set_ylabel('Количество', fontweight='bold')
|
||
|
||
# Добавляем подписи значений
|
||
for bar, val in zip(bars, values):
|
||
height = bar.get_height()
|
||
ax.text(bar.get_x() + bar.get_width()/2., height,
|
||
f'{val}',
|
||
ha='center', va='bottom', fontweight='bold')
|
||
|
||
plt.tight_layout()
|
||
full_path = os.path.join(SCRIPT_DIR, filename)
|
||
plt.savefig(full_path, dpi=150, bbox_inches='tight')
|
||
plt.close()
|
||
print(f"✅ График 3 сохранен: {full_path}")
|
||
|
||
def build_pdf_content(story, registry_stats, audit_data, criteria_stats):
|
||
"""Сформировать содержимое PDF"""
|
||
|
||
# Заголовок
|
||
story.append(Spacer(1, 0.5*inch))
|
||
story.append(Paragraph("ИТОГИ АУДИТА ПРОЗРАЧНОСТИ", title_style))
|
||
story.append(Paragraph("ГОСТИНИЧНОГО СЕКТОРА", title_style))
|
||
story.append(Paragraph("Орловской области", title_style))
|
||
story.append(Spacer(1, 0.3*inch))
|
||
|
||
date_str = datetime.now().strftime("%d.%m.%Y")
|
||
story.append(Paragraph(f"Дата формирования отчета: {date_str}",
|
||
styles['Normal']))
|
||
story.append(PageBreak())
|
||
|
||
# Введение
|
||
story.append(Paragraph("ВВЕДЕНИЕ", heading_style))
|
||
intro_text = """
|
||
<b>Завершен независимый аудит прозрачности и соблюдения требований законодательства
|
||
в гостиничной индустрии Орловской области.</b><br/><br/>
|
||
|
||
Исследование охватило действующие средства размещения, зарегистрированные
|
||
в государственном реестре. Проверка осуществлялась по 18 ключевым критериям прозрачности,
|
||
включая юридическую идентификацию, контактную информацию, политику конфиденциальности,
|
||
реестр РКН, договор-оферту, механизмы обращения граждан, прайс-листы, способы оплаты,
|
||
систему бронирования и другие аспекты информационной открытости.
|
||
"""
|
||
story.append(Paragraph(intro_text, normal_style))
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# Основные результаты
|
||
total_hotels = len(audit_data)
|
||
avg_score = sum(h['score_percentage'] for h in audit_data) / total_hotels if total_hotels > 0 else 0
|
||
total_with_website = sum(1 for h in audit_data if h['has_website'])
|
||
total_compliant = sum(1 for h in audit_data if h['score_percentage'] >= 50)
|
||
|
||
story.append(Paragraph("ОСНОВНЫЕ РЕЗУЛЬТАТЫ АУДИТА", heading_style))
|
||
|
||
results_text = f"""
|
||
<b>По данным государственного реестра средств размещения:</b><br/>
|
||
• Всего действующих объектов размещения: <b>{registry_stats['total_in_registry']}</b><br/>
|
||
• Отелей с указанными сайтами в реестре: <b>{registry_stats['with_websites']}</b><br/>
|
||
• Доступных сайтов на момент проверки: <b>{registry_stats['accessible_websites']}</b><br/><br/>
|
||
|
||
<b>В рамках аудита проведена комплексная проверка:</b><br/>
|
||
• Обработано действующих отелей: <b>{total_hotels}</b><br/>
|
||
• Обладают рабочими сайтами: <b>{total_with_website} ({total_with_website/total_hotels*100:.1f}%)</b><br/>
|
||
• Средний балл соответствия требованиям: <b>{avg_score:.1f}%</b><br/>
|
||
• Отелей с оценкой ≥50%: <b>{total_compliant} ({total_compliant/total_hotels*100:.1f}%)</b>
|
||
"""
|
||
story.append(Paragraph(results_text, normal_style))
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# График распределения
|
||
temp_file1 = 'temp_chart1.png'
|
||
create_score_distribution_chart(audit_data, temp_file1)
|
||
full_path1 = os.path.join(SCRIPT_DIR, temp_file1)
|
||
img1 = Image(full_path1, width=6*inch, height=5*inch)
|
||
story.append(img1)
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# Статистика по критериям
|
||
story.append(Paragraph("СТАТИСТИКА ПО КРИТЕРИЯМ", heading_style))
|
||
|
||
sorted_criteria = sorted(criteria_stats.items(),
|
||
key=lambda x: x[1]['found']/x[1]['total'] if x[1]['total'] > 0 else 0,
|
||
reverse=True)
|
||
|
||
top_success = sorted_criteria[:5]
|
||
top_problems = sorted_criteria[-5:]
|
||
|
||
# Топ успехов
|
||
story.append(Paragraph("Наиболее активно соблюдаются:", subheading_style))
|
||
for idx, (name, stats) in enumerate(top_success, 1):
|
||
percentage = stats['found'] / stats['total'] * 100 if stats['total'] > 0 else 0
|
||
story.append(Paragraph(
|
||
f"{idx}. {name}: <b>{percentage:.0f}%</b> ({stats['found']} из {stats['total']})",
|
||
normal_style
|
||
))
|
||
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# Топ проблем
|
||
story.append(Paragraph("Требуют особого внимания:", subheading_style))
|
||
for idx, (name, stats) in enumerate(reversed(top_problems), 1):
|
||
percentage = stats['found'] / stats['total'] * 100 if stats['total'] > 0 else 0
|
||
story.append(Paragraph(
|
||
f"{idx}. {name}: <b>{percentage:.0f}%</b> ({stats['found']} из {stats['total']})",
|
||
normal_style
|
||
))
|
||
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# График по критериям
|
||
temp_file2 = 'temp_chart2.png'
|
||
create_criteria_chart(criteria_stats, temp_file2)
|
||
full_path2 = os.path.join(SCRIPT_DIR, temp_file2)
|
||
img2 = Image(full_path2, width=7*inch, height=4*inch)
|
||
story.append(img2)
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# Сводный график
|
||
temp_file3 = 'temp_chart3.png'
|
||
create_summary_chart(registry_stats, audit_data, temp_file3)
|
||
full_path3 = os.path.join(SCRIPT_DIR, temp_file3)
|
||
img3 = Image(full_path3, width=5*inch, height=3*inch)
|
||
story.append(img3)
|
||
story.append(Spacer(1, 0.2*inch))
|
||
|
||
# Заключение
|
||
story.append(PageBreak())
|
||
story.append(Paragraph("ЗАКЛЮЧЕНИЕ", heading_style))
|
||
|
||
conclusion_text = f"""
|
||
Аудит показывает, что гостиничный сектор Орловской области активно внедряет
|
||
практики прозрачности и соблюдения потребительских прав. Средний балл соответствия
|
||
<b>{avg_score:.1f}%</b> указывает на необходимость дальнейшей работы по совершенствованию
|
||
доступности информации для гостей и улучшению качества предоставляемых услуг.<br/><br/>
|
||
|
||
Выявлены области, требующие особого внимания: улучшение политики конфиденциальности
|
||
(152-ФЗ), усиление информации о правилах оказания услуг и механизмах решения споров,
|
||
расширение контента о доступности для маломобильных групп населения.<br/><br/>
|
||
|
||
Положительным моментом является высокий процент отелей с рабочими сайтами
|
||
({total_with_website/total_hotels*100:.1f}%), что демонстрирует готовность отрасли
|
||
к цифровизации и открытости перед потребителем.<br/><br/>
|
||
|
||
Полученные данные будут направлены владельцам объектов размещения для информирования
|
||
о выявленных областях улучшения и передовых практиках.
|
||
"""
|
||
story.append(Paragraph(conclusion_text, normal_style))
|
||
|
||
story.append(Spacer(1, 0.3*inch))
|
||
story.append(Paragraph(
|
||
"<i>Дополнительная информация по результатам аудита доступна в приложенном файле Excel.</i>",
|
||
styles['Italic']
|
||
))
|
||
|
||
# Временные файлы будут удалены после создания PDF
|
||
|
||
def main():
|
||
print(f"📄 СОЗДАНИЕ PDF ОТЧЕТА ПО {REGION}")
|
||
print("=" * 60)
|
||
|
||
# Получаем данные
|
||
print("📊 Загружаем данные из БД...")
|
||
registry_stats, audit_data, criteria_stats = get_database_stats()
|
||
|
||
print(f"✅ Загружено:")
|
||
print(f" 🏨 Отелей: {len(audit_data)}")
|
||
print(f" 🎯 Критериев: {len(criteria_stats)}")
|
||
|
||
# Создаем PDF
|
||
print("\n📝 Создаем PDF файл...")
|
||
filename = f"audit_report_orel_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||
|
||
doc = SimpleDocTemplate(filename, pagesize=A4,
|
||
rightMargin=72, leftMargin=72,
|
||
topMargin=72, bottomMargin=18)
|
||
|
||
story = []
|
||
build_pdf_content(story, registry_stats, audit_data, criteria_stats)
|
||
|
||
doc.build(story)
|
||
|
||
# Удаляем временные файлы
|
||
for temp_file in ['temp_chart1.png', 'temp_chart2.png', 'temp_chart3.png']:
|
||
full_path = os.path.join(SCRIPT_DIR, temp_file)
|
||
if os.path.exists(full_path):
|
||
os.remove(full_path)
|
||
|
||
print(f"\n✅ PDF отчет создан: {filename}")
|
||
print(f"📊 Включено:")
|
||
print(f" 📈 Графиков: 3")
|
||
print(f" 📑 Страниц: ~{len([s for s in story if isinstance(s, PageBreak)]) + 1}")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
|