diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py index 187c72d..4137930 100644 --- a/apps/reports/services/aggregation.py +++ b/apps/reports/services/aggregation.py @@ -81,6 +81,15 @@ def _serialize_money_totals(values: dict[str, Decimal]) -> list[dict]: ] +def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None: + if amount is None: + return None + return { + "amount": f"{Decimal(amount).quantize(Decimal('0.01'))}", + "currency": currency or "USD", + } + + def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry): if not entry.is_billable or not entry.hourly_rate: return @@ -438,17 +447,18 @@ def _scope_payload(filters: ReportFilters) -> dict: def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: summary = _summary_from_entries(entries) + include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) return { "scope": _scope_payload(filters), "summary": summary, - "days": _group_daily(entries), + "days": _group_daily(entries, include_latest_rate=include_latest_rate), "clients": _build_breakdown(entries, "clients"), "projects": _build_breakdown(entries, "projects"), "tags": _build_breakdown(entries, "tags"), } -def _group_daily(entries: list[TimeEntry]) -> list[dict]: +def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]: by_day: dict[str, dict] = {} for entry in entries: local_start = _localize_datetime(entry.start_time) @@ -461,6 +471,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]: "non_billable_seconds": 0, "total_seconds": 0, "income": _money_map(), + "latest_rate_amount": None, + "latest_rate_currency": None, + "latest_rate_timestamp": None, }, ) duration_seconds = get_entry_duration_seconds(entry) @@ -470,6 +483,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]: else: day_bucket["non_billable_seconds"] += duration_seconds _add_income(day_bucket["income"], entry) + if ( + include_latest_rate + and entry.is_billable + and entry.hourly_rate + and ( + day_bucket["latest_rate_timestamp"] is None + or local_start >= day_bucket["latest_rate_timestamp"] + ) + ): + day_bucket["latest_rate_amount"] = Decimal(entry.hourly_rate) + day_bucket["latest_rate_currency"] = entry.currency or "USD" + day_bucket["latest_rate_timestamp"] = local_start rows = [] for day_key in sorted(by_day.keys()): @@ -483,6 +508,10 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]: "billable_duration": _format_duration_seconds(bucket["billable_seconds"]), "non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]), "total_duration": _format_duration_seconds(bucket["total_seconds"]), + "latest_hourly_rate": _serialize_rate( + bucket["latest_rate_amount"], + bucket["latest_rate_currency"], + ) if include_latest_rate else None, "income_totals": _serialize_money_totals(bucket["income"]), } ) diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py index 593fe14..2845e05 100644 --- a/apps/reports/services/export_i18n.py +++ b/apps/reports/services/export_i18n.py @@ -29,12 +29,14 @@ TRANSLATIONS = { "from_date": "From date", "to_date": "To date", "user": "User", + "mobile": "Mobile", "all_users": "All users", "generated_at": "Generated at", "summary": "Summary", "total_hours": "Total hours", "billable_hours": "Billable hours", "non_billable_hours": "Non-billable hours", + "hourly_rate": "Hourly rate", "income": "Income", "daily_summary": "Daily Summary", "clients": "Clients", @@ -53,12 +55,14 @@ TRANSLATIONS = { "from_date": "از تاریخ", "to_date": "تا تاریخ", "user": "کاربر", + "mobile": "موبایل", "all_users": "همه کاربران", "generated_at": "تاریخ تولید", "summary": "خلاصه", "total_hours": "کل ساعات", "billable_hours": "ساعات کاری", "non_billable_hours": "ساعات غیر کاری", + "hourly_rate": "نرخ ساعتی", "income": "درآمد", "daily_summary": "خلاصه روزانه", "clients": "مشتریان", diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index 2f0c6b7..d8b4224 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -63,6 +63,18 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str: return locale.format_money_label(income_totals, ascii_digits=True) +def _rate_label(locale: ExportLocale, rate: dict | None) -> str: + if not rate: + return "-" + return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}" + + +def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: + if not rate: + return "-" + return f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" + + def _section_headers(locale: ExportLocale) -> list[str]: headers = [ locale.t("name"), @@ -87,7 +99,24 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])])) worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) - worksheet.append(_rtl_row(locale, [locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)])) + worksheet.append( + _rtl_row( + locale, + [ + locale.t("user"), + scope["user"]["name"] if scope.get("user") else locale.t("all_users"), + ], + ) + ) + worksheet.append( + _rtl_row( + locale, + [ + locale.t("mobile"), + locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-", + ], + ) + ) worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])) worksheet.append([]) worksheet.append([locale.t("summary")]) @@ -99,12 +128,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> for row_index in range(1, worksheet.max_row + 1): first_cell = worksheet.cell(row=row_index, column=1) second_cell = worksheet.cell(row=row_index, column=2) - if row_index in {1, 9}: + if row_index in {1, 10}: _apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) if second_cell.value: _apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl) elif first_cell.value: - _apply_cell_style(first_cell, bold=False, fill=SECTION_FILL if row_index == 8 else None, rtl=locale.is_rtl) + _apply_cell_style(first_cell, bold=False, fill=None, rtl=locale.is_rtl) if second_cell.value: _apply_cell_style(second_cell, rtl=locale.is_rtl) @@ -121,6 +150,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - locale.t("billable_hours"), locale.t("non_billable_hours"), locale.t("total_hours"), + locale.t("hourly_rate"), locale.t("income"), ], ) @@ -142,6 +172,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["non_billable_duration"], ascii_digits=True), locale.format_duration(row["total_duration"], ascii_digits=True), + _rate_label_excel(locale, row.get("latest_hourly_rate")), _money_label_excel(locale, row["income_totals"]), ], ) @@ -157,6 +188,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True), locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True), locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True), + "-", _money_label_excel(locale, report_data["summary"]["income_totals"]), ], ) @@ -261,18 +293,20 @@ def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: return table -def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]]: +def _report_table_rows(locale: ExportLocale, rows: list[dict], *, is_daily: bool) -> list[list[str]]: if not rows: - return [_rtl_row(locale, [locale.t("no_data"), "", "", "", ""])] + column_count = 6 if is_daily else 5 + return [_rtl_row(locale, [locale.t("no_data"), *([""] * (column_count - 1))])] return [ _rtl_row( locale, [ - locale.format_date(row.get("date")) if row.get("date") else row["name"], - locale.format_duration(row["billable_duration"]), - locale.format_duration(row["non_billable_duration"]), - locale.format_duration(row["total_duration"]), - _money_label(locale, row["income_totals"]), + locale.format_date(row.get("date")) if row.get("date") else row["name"], + locale.format_duration(row["billable_duration"]), + locale.format_duration(row["non_billable_duration"]), + locale.format_duration(row["total_duration"]), + *([_rate_label(locale, row.get("latest_hourly_rate"))] if is_daily else []), + _money_label(locale, row["income_totals"]), ], ) for row in rows @@ -360,7 +394,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: [locale.t("period"), locale.period_label(scope["period"])], [locale.t("from_date"), locale.format_date(scope["from_date"])], [locale.t("to_date"), locale.format_date(scope["to_date"])], - [locale.t("user"), user_label(scope.get("user"), locale)], + [locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users")], + [locale.t("mobile"), locale.format_number(scope["user"]["mobile"]) if scope.get("user") and scope["user"].get("mobile") else "-"], [locale.t("generated_at"), locale.format_date(datetime.now().date())], ] if locale.is_rtl: @@ -421,23 +456,35 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: header = _rtl_row( locale, [ - locale.t("date") if is_daily else locale.t("name"), - locale.t("billable_hours"), - locale.t("non_billable_hours"), - locale.t("total_hours"), - locale.t("income"), + locale.t("date") if is_daily else locale.t("name"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + *( [locale.t("hourly_rate")] if is_daily else [] ), + locale.t("income"), ], ) table = _styled_table( - [header, *_report_table_rows(locale, rows)], + [header, *_report_table_rows(locale, rows, is_daily=is_daily)], locale=locale, - column_widths=[ - doc.width * 0.26, - doc.width * 0.15, - doc.width * 0.17, - doc.width * 0.14, - doc.width * 0.28, - ], + column_widths=( + [ + doc.width * 0.21, + doc.width * 0.13, + doc.width * 0.15, + doc.width * 0.13, + doc.width * 0.16, + doc.width * 0.22, + ] + if is_daily + else [ + doc.width * 0.26, + doc.width * 0.15, + doc.width * 0.17, + doc.width * 0.14, + doc.width * 0.28, + ] + ), ) story.extend([table, Spacer(1, 5 * mm)]) diff --git a/apps/reports/tests/test_tasks.py b/apps/reports/tests/test_tasks.py index 497ae6d..e0b236d 100644 --- a/apps/reports/tests/test_tasks.py +++ b/apps/reports/tests/test_tasks.py @@ -171,6 +171,53 @@ def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope( assert len(workbook.sheetnames) == 3 +def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta( + fake_redis, + workspace, + owner, + time_entry, +): + job = ReportExportJob.objects.create( + requesting_user=owner, + workspace=workspace, + export_type=ReportExportJob.ExportType.EXCEL, + filters={ + "workspace": str(workspace.id), + "period": "this_month", + "from_date": "2026-04-01", + "to_date": "2026-04-30", + "user": str(owner.id), + "client": None, + "project": None, + "tags": [], + "language": "en", + }, + ) + + generate_report_export_task(str(job.id)) + job.refresh_from_db() + + workbook = load_workbook(BytesIO(job.file.read())) + worksheet = workbook.active + values = list(worksheet.iter_rows(values_only=True)) + + assert any(row[:2] == ("User", "Owner User") for row in values if row) + assert any(row[:2] == ("Mobile", "09129990001") for row in values if row) + + daily_header = next(row[:6] for row in values if row and row[0] == "Date") + assert daily_header == ( + "Date", + "Billable hours", + "Non-billable hours", + "Total hours", + "Hourly rate", + "Income", + ) + + daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12") + assert daily_row[4] == "15 USD" + + def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry): job = ReportExportJob.objects.create( requesting_user=owner, diff --git a/apps/reports/tests/test_views.py b/apps/reports/tests/test_views.py index f492141..9825122 100644 --- a/apps/reports/tests/test_views.py +++ b/apps/reports/tests/test_views.py @@ -108,6 +108,48 @@ def test_admin_can_request_combined_table_report(api_client, admin, workspace, t assert response.status_code == 200 assert response.data["summary"]["total_duration"] == "03:00:00" assert len(response.data["days"]) == 2 + assert response.data["days"][0]["latest_hourly_rate"] is None + assert response.data["days"][1]["latest_hourly_rate"] is None + + +def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project): + api_client.force_authenticate(user=owner) + + TimeEntry.objects.create( + workspace=workspace, + user=owner, + project=project, + description="Morning work", + start_time="2026-04-15T08:00:00+03:30", + end_time="2026-04-15T09:00:00+03:30", + duration=timedelta(hours=1), + is_billable=True, + hourly_rate=Decimal("20.00"), + currency="USD", + ) + TimeEntry.objects.create( + workspace=workspace, + user=owner, + project=project, + description="Later work", + start_time="2026-04-15T13:00:00+03:30", + end_time="2026-04-15T15:00:00+03:30", + duration=timedelta(hours=2), + is_billable=True, + hourly_rate=Decimal("35.00"), + currency="USD", + ) + + response = api_client.get( + "/api/reports/table/", + {"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)}, + ) + + assert response.status_code == 200 + assert response.data["days"][0]["latest_hourly_rate"] == { + "amount": "35.00", + "currency": "USD", + } def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):