from typing import Dict, List, Tuple from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet from ..report_builder import group_rows_by_project_and_version from ..types import ReportRow from ..utils import hours_to_human from .base import Formatter class XLSXFormatter(Formatter): """Форматтер для экспорта отчёта в Excel (.xlsx). Использует группировку по проекту и версии: объединяет ячейки, добавляет итоги по группам, закрепляет заголовок, включает автофильтр и числовой столбец с часами для удобного суммирования. """ _HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") _TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") _BORDER = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) def __init__(self, no_time: bool = False, **_kwargs): super().__init__() self.no_time = no_time def format(self, rows: List[ReportRow]) -> Workbook: wb = Workbook() ws = wb.active if ws is None: ws = wb.create_sheet("Report") else: ws.title = "Report" headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"] ws.append(headers) self._style_header_row(ws, headers) grouped = group_rows_by_project_and_version(rows) current_row = 2 project_ranges: List[Tuple[int, int]] = [] version_ranges: List[Tuple[int, int]] = [] project_totals: Dict[str, float] = {} for project, versions in grouped.items(): project_start_row = current_row for version, task_rows in versions.items(): version_start_row = current_row for r in task_rows: hours = "" if self.no_time else r.get("hours", 0.0) time_text = "" if self.no_time else r["time_text"] ws.append( [ project, version, r["issue_id"], r["subject"], r["status_ru"], hours, time_text, ] ) self._style_data_row(ws, current_row) current_row += 1 if not self.no_time: version_hours = sum(r.get("hours", 0.0) for r in task_rows) ws.append( [ "", f"Total {version}", "", "", "", version_hours, hours_to_human(version_hours), ] ) self._style_total_row(ws, current_row, bold=False) ws.merge_cells( start_row=current_row, start_column=2, end_row=current_row, end_column=5 ) current_row += 1 version_end_row = current_row - 1 if version_end_row > version_start_row: version_ranges.append((version_start_row, version_end_row)) if not self.no_time: project_hours = sum( sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values() ) ws.append( [ f"Total {project}", "", "", "", "", project_hours, hours_to_human(project_hours), ] ) self._style_total_row(ws, current_row, bold=True) ws.merge_cells( start_row=current_row, start_column=1, end_row=current_row, end_column=5 ) current_row += 1 project_totals[project] = project_hours project_end_row = current_row - 1 if project_end_row > project_start_row: project_ranges.append((project_start_row, project_end_row)) if not self.no_time and project_totals: total_hours = sum(project_totals.values()) ws.append( [ "Total", "", "", "", "", total_hours, hours_to_human(total_hours), ] ) self._style_total_row(ws, current_row, bold=True) ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5) for start, end in project_ranges: ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1) cell = ws.cell(row=start, column=1) cell.alignment = Alignment(vertical="top", wrap_text=True) for start, end in version_ranges: ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2) cell = ws.cell(row=start, column=2) cell.alignment = Alignment(vertical="top", wrap_text=True) self._apply_column_widths(ws) if not self.no_time: self._apply_number_format(ws) self._apply_auto_filter(ws, ws.max_row) ws.freeze_panes = "A2" return wb def save(self, rows: List[ReportRow], output_path: str) -> None: wb = self.format(rows) wb.save(output_path) def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None: for col_idx, _ in enumerate(headers, start=1): cell = ws.cell(row=1, column=col_idx) cell.font = Font(bold=True) cell.fill = self._HEADER_FILL cell.border = self._BORDER cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) def _style_data_row(self, ws: Worksheet, row: int) -> None: for col_idx in range(1, 8): cell = ws.cell(row=row, column=col_idx) cell.border = self._BORDER if col_idx in (1, 2): cell.alignment = Alignment(vertical="top", wrap_text=True) elif col_idx == 4: cell.alignment = Alignment(vertical="top", wrap_text=True) else: cell.alignment = Alignment(vertical="top") def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None: for col_idx in range(1, 8): cell = ws.cell(row=row, column=col_idx) cell.border = self._BORDER cell.fill = self._TOTAL_FILL cell.font = Font(bold=bold) cell.alignment = Alignment(vertical="center") def _apply_column_widths(self, ws: Worksheet) -> None: # Минимальные ширины по умолчанию widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0} for row in ws.iter_rows(min_row=2, max_row=ws.max_row): for col_idx, cell in enumerate(row, start=1): if cell.value is None: continue text = str(cell.value) # Оценочная ширина: примерно 1.1 символа на единицу ширины Excel estimated = len(text) * 1.1 + 2 widths[col_idx] = max(widths[col_idx], min(estimated, 80)) for col_idx, width in widths.items(): ws.column_dimensions[get_column_letter(col_idx)].width = width def _apply_number_format(self, ws: Worksheet) -> None: for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6): for cell in row: if isinstance(cell.value, (int, float)): cell.number_format = "0.00" def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None: ws.auto_filter.ref = f"A1:G{max_row}"