Добавлен генератор PDF отчетов с графиками и поддержкой кириллицы

- Создан create_pdf_report.py для генерации PDF отчётов
- Поддержка кириллицы через DejaVu Sans шрифты
- 3 типа графиков: распределение по баллам, топ-10 критериев, общая статистика
- Отчёт для Орловской области: 259KB, 5 страниц
- Обновлен create_horizontal_report.py
This commit is contained in:
Фёдор
2025-10-28 00:33:33 +03:00
parent 5e807fd7ce
commit 54e2206234
3 changed files with 904 additions and 28 deletions

File diff suppressed because one or more lines are too long

View File

@@ -294,55 +294,61 @@ def create_dashboard_sheet(workbook, audit_data, criteria_stats):
ws.cell(row=current_row, column=col).font = normal_font ws.cell(row=current_row, column=col).font = normal_font
ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center') ws.cell(row=current_row, column=col).alignment = Alignment(horizontal='center')
# Графики # Графики с динамическими ссылками
# Круговой график статуса сайтов # 1. Круговой график - распределение отелей по статусу
pie_chart = PieChart() pie_chart = PieChart()
pie_chart.title = "Статус сайтов отелей" 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.height = 10
pie_chart.width = 15 pie_chart.width = 15
# Добавляем подписи данных # Данные для пирога из таблицы распределения по баллам
pie_data = Reference(ws, min_col=2, min_row=score_header_row+1, max_row=score_data_start+len(score_ranges)-1, max_col=2)
pie_labels = Reference(ws, min_col=1, min_row=score_header_row+1, max_row=score_data_start+len(score_ranges)-1)
pie_chart.add_data(pie_data, titles_from_data=False)
pie_chart.set_categories(pie_labels)
# Подписи данных
pie_chart.dataLabels = DataLabelList() pie_chart.dataLabels = DataLabelList()
pie_chart.dataLabels.showPercent = True pie_chart.dataLabels.showPercent = True
pie_chart.dataLabels.showCategoryName = True pie_chart.dataLabels.showCategoryName = True
ws.add_chart(pie_chart, "C3") # Добавляем график справа от статистики
ws.add_chart(pie_chart, "J3")
# Столбчатый график по критериям # 2. Столбчатый график - результаты по критериям (топ-10)
chart1 = BarChart() chart1 = BarChart()
chart1.title = "Результаты по критериям" chart1.title = "Топ-10 критериев (найдено / не найдено)"
chart1.x_axis.title = "Критерии" chart1.x_axis.title = "Критерии"
chart1.y_axis.title = "Количество отелей" chart1.y_axis.title = "Количество отелей"
chart1.height = 12
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 chart1.width = 20
ws.add_chart(chart1, "C20") # Берем топ-10 критериев
top_n = min(10, len(criteria_stats))
# График распределения по баллам data = Reference(ws, min_col=2, min_row=data_start_row, max_row=data_start_row+top_n-1, max_col=3)
cats = Reference(ws, min_col=1, min_row=data_start_row, max_row=data_start_row+top_n-1)
chart1.add_data(data, titles_from_data=False)
chart1.set_categories(cats)
# Добавляем график справа от таблицы критериев
ws.add_chart(chart1, "J20")
# 3. Столбчатый график распределения по баллам
chart2 = BarChart() chart2 = BarChart()
chart2.title = "Распределение по баллам" chart2.title = "Распределение отелей по баллам"
chart2.x_axis.title = "Диапазон баллов" chart2.x_axis.title = "Диапазон баллов"
chart2.y_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.height = 8
chart2.width = 12 chart2.width = 12
ws.add_chart(chart2, "C40") data2 = Reference(ws, min_col=2, min_row=score_header_row+1, max_row=score_data_start+len(score_ranges)-1, max_col=2)
cats2 = Reference(ws, min_col=1, min_row=score_header_row+1, max_row=score_data_start+len(score_ranges)-1)
chart2.add_data(data2, titles_from_data=False)
chart2.set_categories(cats2)
# Добавляем график рядом с таблицей распределения
ws.add_chart(chart2, "J40")
# Настройка ширины колонок # Настройка ширины колонок
column_widths = [30, 10, 10] column_widths = [30, 10, 10]

429
create_pdf_report.py Normal file
View File

@@ -0,0 +1,429 @@
#!/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()