diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py index 096683f..9ceb476 100644 --- a/apps/reports/services/aggregation.py +++ b/apps/reports/services/aggregation.py @@ -18,8 +18,7 @@ from apps.projects.models import Project from apps.projects.services.access import user_has_project_access from apps.tags.models import Tag from apps.time_entries.models import TimeEntry -from apps.workspaces.models import Workspace -from apps.workspaces.models import WorkspaceUserRate +from apps.workspaces.models import HourlyRateHistory, Workspace from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability User = get_user_model() @@ -121,113 +120,73 @@ def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]: ] -def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]: - sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id)) - periods: list[dict] = [] - current: dict | None = None +def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]: + current_timezone = timezone.get_current_timezone() + period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone) + period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone) + rows = list( + HourlyRateHistory.objects.filter( + workspace=workspace, + user=user, + is_deleted=False, + effective_from__lt=period_end, + ) + .select_related("project") + .order_by("scope", "project_id", "effective_from", "created_at") + ) - for entry in sorted_entries: - if not entry.hourly_rate or not entry.start_time: - continue + grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list) + for row in rows: + grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row) - amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}" - currency = entry.currency or "USD" - start_date = _localize_datetime(entry.start_time).date() - end_source = entry.end_time or entry.start_time - end_date = _localize_datetime(end_source).date() + serialized: list[dict] = [] + for (_scope, _project_id), history_rows in grouped.items(): + selected_indexes = { + index for index, row in enumerate(history_rows) if row.effective_from >= period_start + } + previous_indexes = [ + index for index, row in enumerate(history_rows) if row.effective_from < period_start + ] + if previous_indexes: + selected_indexes.add(previous_indexes[-1]) - if ( - current - and current["amount"] == amount - and current["currency"] == currency - ): - if end_date > current["to_date"]: - current["to_date"] = end_date - continue - - if current: - periods.append( + for index in sorted(selected_indexes): + row = history_rows[index] + next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None + if next_row and next_row.effective_from < period_start: + continue + from_day = max(_localize_datetime(row.effective_from).date(), from_date) + to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None + serialized.append( { - "amount": current["amount"], - "currency": current["currency"], - "from_date": current["from_date"].isoformat(), - "to_date": current["to_date"].isoformat(), + "amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}", + "currency": row.currency or "USD", + "from_date": from_day.isoformat(), + "to_date": to_day.isoformat() if to_day else None, + "scope": row.scope, + "project_name": row.project.name if row.project else None, + "is_current": next_row is None, } ) - current = { - "amount": amount, - "currency": currency, - "from_date": start_date, - "to_date": end_date, - } - - if current: - periods.append( - { - "amount": current["amount"], - "currency": current["currency"], - "from_date": current["from_date"].isoformat(), - "to_date": current["to_date"].isoformat(), - } - ) - - return periods - - -def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]: - workspace_rate = ( - WorkspaceUserRate.objects.filter( - workspace=workspace, - user=user, - is_active=True, - is_deleted=False, - ) - .order_by("-effective_from", "-updated_at") - .first() - ) - if not workspace_rate or not workspace_rate.effective_from: - return [] - return [ - { - "amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}", - "currency": workspace_rate.currency or "USD", - "from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(), - "to_date": None, - "is_current": True, - } - ] - - -def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]: - merged = [dict(row) for row in history_rows] - latest_indexes = { - (row["amount"], row["currency"]): index - for index, row in enumerate(merged) - } - - for row in current_rows: - key = (row["amount"], row["currency"]) - index = latest_indexes.get(key) - if index is not None: - merged[index]["to_date"] = None - continue - - merged.append(dict(row)) - latest_indexes[key] = len(merged) - 1 - return sorted( - merged, + serialized, key=lambda item: ( item["from_date"], - item["currency"], + item["scope"], + item.get("project_name") or "", Decimal(item["amount"]), - item.get("to_date") or "9999-12-31", ), ) def _uncategorized_label(kind: str, language: str) -> str: + if language == "fa": + return { + "clients": "بدون مشتری", + "projects": "بدون پروژه", + "tags": "بدون تگ", + }[kind] resolved_language = language if language in UNCATEGORIZED_LABELS else "en" return UNCATEGORIZED_LABELS[resolved_language][kind] @@ -422,11 +381,22 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income)) -def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict: +def _build_user_summary( + user, + entries: list[TimeEntry], + *, + workspace: Workspace, + from_date: date, + to_date: date, + language: str, +) -> dict: summary = _summary_from_entries(entries) - historical_rate_rows = _serialize_rate_periods(entries) - current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace) - rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows) + rate_rows = _serialize_rate_history_rows( + user=user, + workspace=workspace, + from_date=from_date, + to_date=to_date, + ) project_rows = _build_breakdown(entries, "projects", language=language) client_rows = _build_breakdown(entries, "clients", language=language) tag_rows = _build_breakdown(entries, "tags", language=language) @@ -458,13 +428,20 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic } -def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]: +def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]: grouped: dict[str, list[TimeEntry]] = defaultdict(list) for entry in entries: grouped[str(entry.user_id)].append(entry) summaries = [ - _build_user_summary(grouped_entries[0].user, grouped_entries, language=language) + _build_user_summary( + grouped_entries[0].user, + grouped_entries, + workspace=filters.workspace, + from_date=filters.from_date, + to_date=filters.to_date, + language=filters.language, + ) for grouped_entries in grouped.values() if grouped_entries ] @@ -799,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]: if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}: bucket_date = local_dt.date() return bucket_date.isoformat(), bucket_date - if filters.language == "fa": - persian_date = jdatetime.date.fromgregorian(date=local_dt.date()) - return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date() bucket_date = date(local_dt.year, local_dt.month, 1) return bucket_date.strftime("%Y-%m"), bucket_date @@ -1045,11 +1019,18 @@ def build_table_report(actor, raw_filters) -> dict: payload = _table_report_payload( filters, entries, - user_summaries=_build_user_summaries(entries, language=filters.language), + user_summaries=_build_user_summaries(entries, filters=filters), ) return payload user_summary = ( - _build_user_summary(entries[0].user, entries, language=filters.language) + _build_user_summary( + entries[0].user, + entries, + workspace=filters.workspace, + from_date=filters.from_date, + to_date=filters.to_date, + language=filters.language, + ) if entries and filters.user_id else None ) @@ -1063,7 +1044,14 @@ def build_user_summary_report(actor, raw_filters) -> dict: entries = list(_base_queryset(filters)) user_summary = ( - _build_user_summary(entries[0].user, entries, language=filters.language) + _build_user_summary( + entries[0].user, + entries, + workspace=filters.workspace, + from_date=filters.from_date, + to_date=filters.to_date, + language=filters.language, + ) if entries else None ) @@ -1095,6 +1083,9 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: user_summary=_build_user_summary( user_entries[0].user, user_entries, + workspace=filters.workspace, + from_date=filters.from_date, + to_date=filters.to_date, language=filters.language, ), ) diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py index be428c5..51bced3 100644 --- a/apps/reports/services/export_i18n.py +++ b/apps/reports/services/export_i18n.py @@ -219,7 +219,7 @@ class ExportLocale: def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: if not income_totals: - return "-" + return self.format_number("0", ascii_digits=ascii_digits) parts = [] for item in income_totals: currency = self.currency_label(item["currency"]) diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index 3d0fd86..448f5c4 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -76,7 +76,7 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str: def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: if not rates: - return "-" + return locale.format_number("0", ascii_digits=ascii_digits) items = [ f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}" for rate in rates @@ -103,13 +103,13 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = def _rate_label(locale: ExportLocale, rate: dict | None) -> str: if not rate: - return "-" + return locale.format_number("0") return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}" def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: if not rate: - return "-" + return locale.format_number("0", ascii_digits=True) value = ( f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} " f"{locale.currency_label(rate['currency'])}" @@ -213,7 +213,14 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary: worksheet.append(row) -def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str: +def _percentage_display( + locale: ExportLocale, + rows: list[dict], + row_data: dict, + *, + ascii_digits: bool = False, + default: str = "0%", +) -> str: 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: @@ -222,7 +229,7 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, return value if row_name and row["name"] == row_name: return value - return "-" + return default def _percentage_number(rows: list[dict] | None, row_data: dict) -> float: @@ -277,7 +284,7 @@ def _summary_breakdown_rows( [ row["name"], _percentage_value(locale, row["percentage"], ascii_digits=True), - _percentage_display(locale, income_rows, row, ascii_digits=True), + _percentage_display(locale, income_rows, row, ascii_digits=True, default="-"), ] for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or "")) ] @@ -304,11 +311,11 @@ def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: b def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: worksheet.append([]) - _append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3) + _append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=4) header_row = worksheet.max_row + 1 worksheet.append( _excel_table_row( - [locale.t("hourly_rate"), locale.t("from"), locale.t("to")], + [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")], ) ) for cell in worksheet[header_row]: @@ -327,6 +334,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su _rate_period_label(locale, row, ascii_digits=True), locale.format_date(row["from_date"], ascii_digits=True), _rate_to_label(locale, row.get("to_date"), ascii_digits=True), + row.get("project_name") or "-", ], ) ) @@ -562,7 +570,7 @@ def _append_breakdown_table( ), _money_label_excel(locale, row["income_totals"]), *( - [_percentage_display(locale, income_percentages or [], row, ascii_digits=True)] + [_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-")] if hour_percentages is not None else [] ), @@ -669,8 +677,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None, *(rate_rows[index] if index < len(rate_rows) else [None, None]), _money_label_excel(locale, summary["income_totals"]) if index == 0 else None, + None, *(client_rows[index] if index < len(client_rows) else [None, None, None]), + None, *(project_rows[index] if index < len(project_rows) else [None, None, None]), + None, *(tag_rows[index] if index < len(tag_rows) else [None, None, None]), ], ) @@ -733,7 +744,7 @@ def _render_all_users_overall_excel_sheet( worksheet, row=15, start_col=1, - end_col=15, + end_col=18, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl, ) @@ -744,12 +755,15 @@ def _render_all_users_overall_excel_sheet( locale.t("hourly_rate"), locale.t("period"), locale.t("income"), + "", locale.t("clients"), locale.t("hour_percentage"), locale.t("income_percentage"), + "", locale.t("projects"), locale.t("hour_percentage"), locale.t("income_percentage"), + "", locale.t("tags"), locale.t("hour_percentage"), locale.t("income_percentage"), @@ -784,19 +798,26 @@ def _render_all_users_overall_excel_sheet( _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True) if len(client_rows) == 1: - _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) - if len(project_rows) == 1: _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True) - _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True) + if len(project_rows) == 1: _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True) - if len(tag_rows) == 1: _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True) - _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True) + if len(tag_rows) == 1: + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True) current_row += span + for row_index in range(16, current_row): + for column_index in (7, 11, 15): + cell = worksheet.cell(row=row_index, column=column_index) + cell.value = None + cell.fill = PatternFill(fill_type=None) + cell.border = Border() + current_row += 2 for title_key, rows, hour_percentages, income_percentages in ( ( @@ -855,7 +876,7 @@ def _render_all_users_overall_excel_sheet( locale.format_duration(row["billable_duration"], ascii_digits=True), _percentage_display(locale, hour_percentages or [], row, ascii_digits=True), _money_label_excel(locale, row["income_totals"]), - _percentage_display(locale, income_percentages or [], row, ascii_digits=True), + _percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-"), ], rtl=locale.is_rtl, ) @@ -1121,7 +1142,7 @@ def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table: rows = summary.get("rate_periods") or [] data = [ - _rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]), + _rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")]), *( _rtl_row( locale, @@ -1129,14 +1150,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width _rate_period_label(locale, row), locale.format_date(row["from_date"]), _rate_to_label(locale, row.get("to_date")), + row.get("project_name") or "-", ], ) for row in rows ), ] if not rows: - data.append(_rtl_row(locale, [locale.t("no_data"), "", ""])) - fixed_widths = [doc_width * 0.18, doc_width * 0.18] + data.append(_rtl_row(locale, [locale.t("no_data"), "", "", ""])) + fixed_widths = [doc_width * 0.18, doc_width * 0.18, doc_width * 0.24] column_widths = [doc_width - sum(fixed_widths), *fixed_widths] if locale.is_rtl: column_widths = list(reversed(column_widths)) @@ -1227,7 +1249,7 @@ def _append_pdf_report_sections( ] ), _money_label(locale, row["income_totals"]), - _percentage_display(locale, income_percentage_rows or [], row), + _percentage_display(locale, income_percentage_rows or [], row, default="-"), ], ) for row in sorted_rows diff --git a/apps/reports/tests/test_exporters.py b/apps/reports/tests/test_exporters.py index a717fa8..c74ead7 100644 --- a/apps/reports/tests/test_exporters.py +++ b/apps/reports/tests/test_exporters.py @@ -199,9 +199,9 @@ class ReportExporterTests(TestCase): self.assertEqual(summary_sheet["A1"].value, "Workspace Report") self.assertEqual(summary_sheet["B1"].value, "Exports") self.assertEqual(summary_sheet["A15"].value, "Users Summary") - self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges}) + self.assertIn("A15:R15", {str(item) for item in summary_sheet.merged_cells.ranges}) self.assertEqual( - tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15], + tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18], ( "Name", "Mobile", @@ -209,12 +209,15 @@ class ReportExporterTests(TestCase): "Hourly rate", "Period", "Income", + None, "Clients", "Hour %", "Income %", + None, "Projects", "Hour %", "Income %", + None, "Tags", "Hour %", "Income %",