Files
ACORE/apps/reports/it_monthly/pdf.py
T
lokses 0da9c13192 it_manager+reports: иерархия договоров, биллинг и месячные отчёты
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг,
месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:44 +03:00

304 lines
11 KiB
Python

"""PDF месячного IT-отчёта (fpdf2, тот же каркас, что Word из report-tool)."""
from __future__ import annotations
from pathlib import Path
from apps.it_manager.report_import import default_report_tool_root, load_company
from apps.reports.it_monthly.docx import (
filled_systems,
filled_tasks,
group_objects_text,
month_title,
report_as_month,
stats_from_report,
)
_FONT_REGULAR = (
Path('/usr/share/fonts/google-noto/NotoSans-Regular.ttf'),
Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'),
Path('/usr/share/fonts/dejavu/DejaVuSans.ttf'),
)
_FONT_BOLD = (
Path('/usr/share/fonts/google-noto/NotoSans-Bold.ttf'),
Path('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'),
Path('/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf'),
)
_FONT_ITALIC = (
Path('/usr/share/fonts/google-noto/NotoSans-Italic.ttf'),
Path('/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf'),
Path('/usr/share/fonts/dejavu/DejaVuSans-Oblique.ttf'),
)
NAVY = (26, 44, 91)
BLUE = (31, 92, 153)
GRAY = (102, 102, 102)
TEXT = (34, 34, 34)
ZEBRA = (244, 247, 251)
HEADER_BG = NAVY
BORDER = (197, 205, 216)
def _first_existing(candidates) -> str | None:
for path in candidates:
if path.is_file():
return str(path)
return None
def _load_company(company: dict | None) -> dict:
if company is not None:
return company
root = default_report_tool_root()
if (root / 'company.json').exists():
return load_company(root)
return {
'company': 'АКС-Аптека',
'department': 'Отдел информационных технологий',
'prepared_by': {'name': '', 'role': ''},
'work_mode': 'Ежедневно (включая выходные)',
'staff': [],
}
class _ReportPDF:
def __init__(self):
from fpdf import FPDF
self.pdf = FPDF(format='A4', unit='mm')
self.pdf.set_auto_page_break(auto=True, margin=16)
self.pdf.set_margins(18, 16, 18)
self.pdf.add_page()
regular = _first_existing(_FONT_REGULAR)
bold = _first_existing(_FONT_BOLD)
italic = _first_existing(_FONT_ITALIC)
self.font = 'Report'
if regular:
self.pdf.add_font('Report', '', regular)
self.pdf.add_font('Report', 'B', bold or regular)
self.pdf.add_font('Report', 'I', italic or regular)
else:
self.font = 'Helvetica'
@property
def w(self) -> float:
return self.pdf.epw
def _ensure_space(self, need_mm: float):
if self.pdf.get_y() + need_mm > self.pdf.page_break_trigger:
self.pdf.add_page()
def title_block(self, company: dict, title: str):
pdf = self.pdf
pdf.set_text_color(*NAVY)
pdf.set_font(self.font, 'I', 16)
pdf.multi_cell(self.w, 8, company.get('company') or 'АКС-Аптека', align='C')
pdf.set_text_color(*GRAY)
pdf.set_font(self.font, 'I', 10)
pdf.multi_cell(self.w, 5, company.get('department') or '', align='C')
pdf.ln(3)
pdf.set_text_color(26, 26, 46)
pdf.set_font(self.font, 'B', 15)
pdf.multi_cell(self.w, 7, 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', align='C')
pdf.set_text_color(*BLUE)
pdf.set_font(self.font, 'B', 12)
pdf.multi_cell(self.w, 6, title, align='C')
pdf.ln(4)
pdf.set_text_color(*TEXT)
def heading(self, text: str):
self._ensure_space(12)
pdf = self.pdf
pdf.ln(2)
pdf.set_text_color(*NAVY)
pdf.set_font(self.font, 'B', 11)
pdf.multi_cell(self.w, 6, text)
y = pdf.get_y()
pdf.set_draw_color(*BLUE)
pdf.set_line_width(0.5)
pdf.line(pdf.l_margin, y, pdf.l_margin + self.w, y)
pdf.ln(2)
pdf.set_text_color(*TEXT)
def subheading(self, text: str, rgb=NAVY):
self._ensure_space(10)
pdf = self.pdf
pdf.set_text_color(*rgb)
pdf.set_font(self.font, 'B', 10)
pdf.multi_cell(self.w, 5.5, text)
pdf.set_text_color(*TEXT)
def intro(self, text: str):
if not text:
return
pdf = self.pdf
pdf.set_text_color(*GRAY)
pdf.set_font(self.font, 'I', 9)
pdf.multi_cell(self.w, 4.5, text)
pdf.set_text_color(*TEXT)
pdf.ln(1)
def paragraph(self, text: str, *, bold=False, size=9.5):
pdf = self.pdf
pdf.set_font(self.font, 'B' if bold else '', size)
pdf.multi_cell(self.w, 4.8, text or '')
def mixed_line(self, label: str, value: str):
pdf = self.pdf
self._ensure_space(6)
pdf.set_font(self.font, 'B', 9.5)
label_w = pdf.get_string_width(label) + 1
pdf.cell(label_w, 5, label)
pdf.set_font(self.font, '', 9.5)
pdf.multi_cell(self.w - label_w, 5, value or '')
def table(self, headers: list[str], rows: list[list[str]], widths: list[float]):
"""Простая таблица с переносом текста в ячейках."""
from fpdf.enums import XPos, YPos
pdf = self.pdf
assert len(headers) == len(widths)
line_h = 4.2
def row_height(cells: list[str], *, header=False) -> float:
pdf.set_font(self.font, 'B' if header else '', 8 if header else 8.5)
tallest = line_h
for i, cell in enumerate(cells):
lines = pdf.multi_cell(widths[i], line_h, str(cell or ''), dry_run=True, output='LINES')
tallest = max(tallest, line_h * max(1, len(lines)))
return tallest + 1.6
def draw_row(cells: list[str], *, header=False, fill=None):
h = row_height(cells, header=header)
self._ensure_space(h + 1)
x0 = pdf.l_margin
y0 = pdf.get_y()
if fill:
pdf.set_fill_color(*fill)
pdf.rect(x0, y0, sum(widths), h, style='F')
pdf.set_draw_color(*BORDER)
pdf.set_line_width(0.2)
pdf.rect(x0, y0, sum(widths), h)
x = x0
for i, w in enumerate(widths[:-1]):
x += w
pdf.line(x, y0, x, y0 + h)
pdf.set_text_color(*(255, 255, 255) if header else TEXT)
pdf.set_font(self.font, 'B' if header else '', 8 if header else 8.5)
x = x0
for i, cell in enumerate(cells):
pdf.set_xy(x + 1.2, y0 + 0.8)
pdf.multi_cell(
widths[i] - 2.4, line_h, str(cell or ''),
new_x=XPos.RIGHT, new_y=YPos.TOP,
)
x += widths[i]
pdf.set_xy(x0, y0 + h)
pdf.set_text_color(*TEXT)
draw_row(headers, header=True, fill=HEADER_BG)
for i, row in enumerate(rows):
padded = list(row) + [''] * (len(headers) - len(row))
draw_row(padded[:len(headers)], fill=ZEBRA if i % 2 else None)
pdf.ln(2)
def report_to_pdf(report: dict, company: dict | None = None) -> bytes:
company = _load_company(company)
month = report_as_month(report, company)
stats = stats_from_report(report, month)
title = month_title(month['year'], month['month'])
doc = _ReportPDF()
doc.title_block(company, title)
info_rows = [
['Отчётный период', stats['period']],
['Состав отдела', f"{stats['staff_count']} сотрудника"],
[
'Охвачено объектов',
f"{stats['objects_count']} аптек и точек" if stats['objects_count'] else '—',
],
['Режим работы', month.get('work_mode') or 'Ежедневно'],
['Всего зафиксированных задач', str(stats['total_tasks'])],
]
doc.table(['Показатель', 'Значение'], info_rows, [70, doc.w - 70])
doc.heading('1. СОСТАВ IT-ОТДЕЛА')
staff_rows = [
[str(i), emp.get('name', ''), emp.get('role', ''), emp.get('zone', '')]
for i, emp in enumerate(month.get('employees') or [], 1)
]
doc.table(
['№', 'Сотрудник', 'Должность / Роль', 'Зона ответственности'],
staff_rows,
[10, 40, 48, doc.w - 98],
)
doc.heading('2. ДЕТАЛЬНЫЙ ОТЧЁТ ПО СОТРУДНИКАМ')
for idx, emp in enumerate(month.get('employees') or [], 1):
color_hex = (emp.get('color') or '1A2C5B').lstrip('#')
try:
rgb = tuple(int(color_hex[i:i + 2], 16) for i in (0, 2, 4))
except ValueError:
rgb = NAVY
doc.subheading(f"2.{idx}. {emp.get('name', '')} — {emp.get('role', '')}", rgb=rgb)
doc.intro(emp.get('intro') or '')
systems = filled_systems(emp)
if systems:
doc.table(
['Система / Направление', 'Выполненные работы'],
[[s['name'], s['work']] for s in systems],
[55, doc.w - 55],
)
tasks = filled_tasks(emp)
if tasks:
doc.table(
['Дата', 'Объект', 'Выполненные работы'],
[[t.get('date', ''), t.get('object', ''), t.get('work', '')] for t in tasks],
[22, 22, doc.w - 44],
)
if not systems and not tasks:
doc.intro('За отчётный период отдельные задачи не внесены.')
n = 3
doc.heading(f'{n}. СВОДНЫЕ ИТОГИ ПО НАПРАВЛЕНИЯМ')
n += 1
summary_rows = [
[s.get('name', ''), str(s.get('count', '')), s.get('people', '')]
for s in stats.get('summary') or []
]
if summary_rows:
doc.table(
['Направление', 'Кол-во задач', 'Основные исполнители'],
summary_rows,
[80, 28, doc.w - 108],
)
else:
doc.intro('Недостаточно данных для сводки.')
doc.heading(f'{n}. ОБЪЕКТЫ, НА КОТОРЫХ ВЫПОЛНЯЛИСЬ РАБОТЫ')
n += 1
for label, value in group_objects_text(stats, month.get('objects_note') or ''):
doc.mixed_line(label + ' ', value)
doc.mixed_line('Итого объектов: ', f"{stats['objects_count']}.")
conclusions = [c for c in (month.get('conclusions') or []) if (c or '').strip()]
if conclusions:
doc.heading(f'{n}. ОСНОВНЫЕ ВЫВОДЫ И РЕКОМЕНДАЦИИ')
for para in conclusions:
if para.lstrip().startswith('◦'):
doc.paragraph(' ' + para.strip(), size=9)
else:
doc.paragraph(para.strip(), size=9.5)
doc.pdf.ln(1)
doc.pdf.ln(8)
doc.mixed_line('Отчёт подготовил: ', month.get('prepared_name') or '')
doc.mixed_line('Должность: ', month.get('prepared_role') or '')
doc.mixed_line('Дата: ', month.get('prepared_date') or '')
doc.paragraph('Подпись: ___________________________', bold=True)
return bytes(doc.pdf.output())