feat(xlsx): make Excel export production-grade

- Add merge cells for project/version groups.
- Add numeric Hours column and human-readable Spent Time.
- Add version/project/grand totals.
- Apply auto-width, freeze panes, auto-filter and styling.
- Respect --no-time: keep columns empty, skip totals.
- Pass no_time flag through formatter factory to all file formatters.
- Add tests for XLSX features and --no-time behavior.

Closes #38
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 14:41:03 +07:00
parent 86f083aa79
commit 222d31730e
8 changed files with 300 additions and 33 deletions

View File

@@ -1,42 +1,214 @@
from typing import List
from typing import Dict, List, Tuple
from openpyxl import Workbook
from openpyxl.styles import Font
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)."""
"""Форматтер для экспорта отчёта в Excel (.xlsx).
def __init__(self, **_kwargs):
Использует группировку по проекту и версии: объединяет ячейки,
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
и числовой столбец с часами для удобного суммирования.
"""
_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
ws.title = "Report"
if ws is None:
ws = wb.create_sheet("Report")
else:
ws.title = "Report"
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
self._style_header_row(ws, headers)
for r in rows:
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(
[
r["project"],
r["version"],
r["issue_id"],
r["subject"],
r["status_ru"],
r["time_text"],
"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}"