0da9c13192
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
11 KiB
Python
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())
|