cli.py: drop redundant 'if issue_hours is None' branch before 'if not issue_hours'. The branch is not formally dead (client returns None when no time entries found, client.py), but it is a semantic duplicate: both branches print the same message and return 0, and None is falsy, so a single 'not issue_hours' check covers None, empty list and keeps behavior identical. test_cli_returns_zero_on_no_entries stays green unchanged. xlsx.py: drop unreachable 'if ws is None' fallback. Workbook() (write_only=False) always creates one worksheet in __init__, so wb.active is never None; set the title directly. Closes #63
243 lines
8.7 KiB
Python
243 lines
8.7 KiB
Python
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
|
||
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}"
|