diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index 0bf5c8a..3d0fd86 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -225,6 +225,41 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, return "-" +def _percentage_number(rows: list[dict] | None, row_data: dict) -> float: + row_id = str(row_data.get("id")) if row_data.get("id") is not None else None + row_name = row_data.get("name") + for row in rows or []: + if row_id is not None and str(row.get("id")) == row_id: + try: + return float(row.get("percentage") or 0) + except (TypeError, ValueError): + return 0.0 + if row_name and row.get("name") == row_name: + try: + return float(row.get("percentage") or 0) + except (TypeError, ValueError): + return 0.0 + return 0.0 + + +def _percentage_sort_value(row: dict) -> float: + try: + return float(row.get("percentage") or 0) + except (TypeError, ValueError): + return 0.0 + + +def _sort_breakdown_rows(rows: list[dict], hour_percentages: list[dict] | None) -> list[dict]: + return sorted( + rows, + key=lambda row: ( + -_percentage_number(hour_percentages, row), + -(row.get("billable_seconds") or 0), + row.get("name") or "", + ), + ) + + def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str: value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%" return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value # Unicode bidi control characters @@ -244,7 +279,7 @@ def _summary_breakdown_rows( _percentage_value(locale, row["percentage"], ascii_digits=True), _percentage_display(locale, income_rows, row, ascii_digits=True), ] - for row in hour_rows + for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or "")) ] @@ -508,7 +543,7 @@ def _append_breakdown_table( _apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl) return - for row in rows: + for row in _sort_breakdown_rows(rows, hour_percentages): values = [ row["name"], locale.format_duration(row["billable_duration"], ascii_digits=True), @@ -808,8 +843,9 @@ def _render_all_users_overall_excel_sheet( fill=HEADER_FILL, ) current_row += 1 - if rows: - for row in rows: + sorted_rows = _sort_breakdown_rows(rows, hour_percentages) + if sorted_rows: + for row in sorted_rows: _write_table_row( worksheet, row=current_row, @@ -1172,7 +1208,8 @@ def _append_pdf_report_sections( hour_percentage_rows = user_summary[f"{prefix}_percentages"] income_percentage_rows = user_summary[f"{prefix}_income_percentages"] header = _rtl_row(locale, header_values) - body_rows = _report_table_rows(locale, rows, is_daily=is_daily) + sorted_rows = rows if is_daily else _sort_breakdown_rows(rows, hour_percentage_rows) + body_rows = _report_table_rows(locale, sorted_rows, is_daily=is_daily) if hour_percentage_rows is not None: body_rows = [ _rtl_row( @@ -1193,7 +1230,7 @@ def _append_pdf_report_sections( _percentage_display(locale, income_percentage_rows or [], row), ], ) - for row in rows + for row in sorted_rows ] or [ _rtl_row( locale, diff --git a/apps/reports/tests/test_exporters.py b/apps/reports/tests/test_exporters.py index 1b65f2e..a717fa8 100644 --- a/apps/reports/tests/test_exporters.py +++ b/apps/reports/tests/test_exporters.py @@ -7,6 +7,7 @@ from apps.reports.services.export_i18n import build_export_locale from apps.reports.services.exporters import ( _pdf_summary_rate_label, _rate_label, + _sort_breakdown_rows, build_excel_report, build_pdf_report, ) @@ -133,6 +134,23 @@ class ReportExporterTests(TestCase): "Variable rate", ) + def test_breakdown_rows_are_sorted_by_hour_percentage(self): + rows = [ + {"id": "low", "name": "Low", "billable_seconds": 7200}, + {"id": "high", "name": "High", "billable_seconds": 3600}, + {"id": "tie", "name": "Tie", "billable_seconds": 10800}, + ] + percentages = [ + {"id": "low", "name": "Low", "percentage": "20"}, + {"id": "high", "name": "High", "percentage": "70"}, + {"id": "tie", "name": "Tie", "percentage": "20"}, + ] + + self.assertEqual( + [row["name"] for row in _sort_breakdown_rows(rows, percentages)], + ["High", "Tie", "Low"], + ) + def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self): locale = build_export_locale("en") report_data = make_report_data(