diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py
index f2459c7..2fd9bdd 100644
--- a/redmine_reporter/formatters/html.py
+++ b/redmine_reporter/formatters/html.py
@@ -1,6 +1,7 @@
from html import escape
-from typing import Dict, List
+from typing import List
+from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow
from .base import Formatter
@@ -12,16 +13,7 @@ class HTMLFormatter(Formatter):
super().__init__()
def format(self, rows: List[ReportRow]) -> str:
- # Сгруппируем данные
- projects: Dict[str, Dict[str, List[ReportRow]]] = {}
- for r in rows:
- proj = r["project"]
- ver = r["version"]
- if proj not in projects:
- projects[proj] = {}
- if ver not in projects[proj]:
- projects[proj][ver] = []
- projects[proj][ver].append(r)
+ projects = group_rows_by_project_and_version(rows)
lines = [
'
',
diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py
index 0d71b2a..ac47947 100644
--- a/redmine_reporter/formatters/odt.py
+++ b/redmine_reporter/formatters/odt.py
@@ -1,11 +1,12 @@
from importlib import resources
-from typing import Dict, List
+from typing import List
from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow
from odf.text import P
+from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow
from ..utils import get_month_name_from_range
from .base import Formatter
@@ -69,18 +70,7 @@ class ODTFormatter(Formatter):
header_row.addElement(cell)
table.addElement(header_row)
- projects: Dict[str, Dict[str, List[ReportRow]]] = {}
- for r in rows:
- project = r["project"]
- version = r["version"]
-
- if project not in projects:
- projects[project] = {}
-
- if version not in projects[project]:
- projects[project][version] = []
-
- projects[project][version].append(r)
+ projects = group_rows_by_project_and_version(rows)
# Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items():
diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py
index 5c0c75e..1f6af6d 100644
--- a/redmine_reporter/report_builder.py
+++ b/redmine_reporter/report_builder.py
@@ -1,4 +1,4 @@
-from typing import List, Tuple, cast
+from typing import Dict, List, Tuple, cast
from redminelib.resources import Issue
@@ -71,3 +71,28 @@ def build_grouped_report(
prev_version = version
return rows
+
+
+def group_rows_by_project_and_version(
+ rows: List[ReportRow],
+) -> Dict[str, Dict[str, List[ReportRow]]]:
+ """
+ Группирует плоский список строк отчёта в иерархию project → version → [rows].
+
+ Предполагается, что rows уже отсортирован (build_grouped_report это гарантирует).
+ Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
+ Используется форматтерами HTML и ODT для объединения ячеек.
+ """
+ projects: Dict[str, Dict[str, List[ReportRow]]] = {}
+ for r in rows:
+ project = r["project"]
+ version = r["version"]
+
+ if project not in projects:
+ projects[project] = {}
+ if version not in projects[project]:
+ projects[project][version] = []
+
+ projects[project][version].append(r)
+
+ return projects
diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py
index fa71904..4ea49a5 100644
--- a/tests/test_report_builder.py
+++ b/tests/test_report_builder.py
@@ -1,4 +1,8 @@
-from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report
+from redmine_reporter.report_builder import (
+ STATUS_TRANSLATION,
+ build_grouped_report,
+ group_rows_by_project_and_version,
+)
class MockIssue:
@@ -117,3 +121,104 @@ def test_build_grouped_report_deterministic_order_within_group():
]
rows = build_grouped_report(issues)
assert [r["issue_id"] for r in rows] == [101, 102, 103]
+
+
+# -- #19: Общая функция группировки --
+
+
+def test_group_rows_basic():
+ """Два проекта, в одном две версии — структура корректна."""
+ rows = [
+ {
+ "project": "A",
+ "version": "v1",
+ "issue_id": 1,
+ "subject": "T1",
+ "status_ru": "S",
+ "time_text": "1ч",
+ },
+ {
+ "project": "A",
+ "version": "v2",
+ "issue_id": 2,
+ "subject": "T2",
+ "status_ru": "S",
+ "time_text": "2ч",
+ },
+ {
+ "project": "B",
+ "version": "v1",
+ "issue_id": 3,
+ "subject": "T3",
+ "status_ru": "S",
+ "time_text": "3ч",
+ },
+ ]
+ grouped = group_rows_by_project_and_version(rows)
+
+ assert list(grouped.keys()) == ["A", "B"]
+ assert list(grouped["A"].keys()) == ["v1", "v2"]
+ assert list(grouped["B"].keys()) == ["v1"]
+ assert len(grouped["A"]["v1"]) == 1
+ assert len(grouped["A"]["v2"]) == 1
+ assert len(grouped["B"]["v1"]) == 1
+
+
+def test_group_rows_multiple_tasks_per_version():
+ """Несколько задач в одной версии — все попадают в список."""
+ rows = [
+ {
+ "project": "A",
+ "version": "v1",
+ "issue_id": 1,
+ "subject": "T1",
+ "status_ru": "S",
+ "time_text": "1ч",
+ },
+ {
+ "project": "A",
+ "version": "v1",
+ "issue_id": 2,
+ "subject": "T2",
+ "status_ru": "S",
+ "time_text": "2ч",
+ },
+ {
+ "project": "A",
+ "version": "v1",
+ "issue_id": 3,
+ "subject": "T3",
+ "status_ru": "S",
+ "time_text": "3ч",
+ },
+ ]
+ grouped = group_rows_by_project_and_version(rows)
+
+ assert len(grouped["A"]["v1"]) == 3
+ assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3]
+
+
+def test_group_rows_preserves_row_data():
+ """Строки в группировке — те же объекты, без потери данных."""
+ rows = [
+ {
+ "project": "P",
+ "version": "v1",
+ "issue_id": 42,
+ "subject": "Task",
+ "status_ru": "Готово",
+ "time_text": "5ч",
+ },
+ ]
+ grouped = group_rows_by_project_and_version(rows)
+
+ row = grouped["P"]["v1"][0]
+ assert row["issue_id"] == 42
+ assert row["subject"] == "Task"
+ assert row["status_ru"] == "Готово"
+ assert row["time_text"] == "5ч"
+
+
+def test_group_rows_empty():
+ """Пустой список — пустой словарь."""
+ assert group_rows_by_project_and_version([]) == {}