feat(reports): add daily rate to report tables and exports

This commit is contained in:
2026-04-28 20:26:20 +03:30
parent 1cd948592c
commit ef05f0a89e
5 changed files with 195 additions and 26 deletions

View File

@@ -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): def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate: if not entry.is_billable or not entry.hourly_rate:
return return
@@ -438,17 +447,18 @@ def _scope_payload(filters: ReportFilters) -> dict:
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return { return {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"days": _group_daily(entries), "days": _group_daily(entries, include_latest_rate=include_latest_rate),
"clients": _build_breakdown(entries, "clients"), "clients": _build_breakdown(entries, "clients"),
"projects": _build_breakdown(entries, "projects"), "projects": _build_breakdown(entries, "projects"),
"tags": _build_breakdown(entries, "tags"), "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] = {} by_day: dict[str, dict] = {}
for entry in entries: for entry in entries:
local_start = _localize_datetime(entry.start_time) local_start = _localize_datetime(entry.start_time)
@@ -461,6 +471,9 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
"non_billable_seconds": 0, "non_billable_seconds": 0,
"total_seconds": 0, "total_seconds": 0,
"income": _money_map(), "income": _money_map(),
"latest_rate_amount": None,
"latest_rate_currency": None,
"latest_rate_timestamp": None,
}, },
) )
duration_seconds = get_entry_duration_seconds(entry) duration_seconds = get_entry_duration_seconds(entry)
@@ -470,6 +483,18 @@ def _group_daily(entries: list[TimeEntry]) -> list[dict]:
else: else:
day_bucket["non_billable_seconds"] += duration_seconds day_bucket["non_billable_seconds"] += duration_seconds
_add_income(day_bucket["income"], entry) _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 = [] rows = []
for day_key in sorted(by_day.keys()): 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"]), "billable_duration": _format_duration_seconds(bucket["billable_seconds"]),
"non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]), "non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]),
"total_duration": _format_duration_seconds(bucket["total_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"]), "income_totals": _serialize_money_totals(bucket["income"]),
} }
) )

View File

@@ -29,12 +29,14 @@ TRANSLATIONS = {
"from_date": "From date", "from_date": "From date",
"to_date": "To date", "to_date": "To date",
"user": "User", "user": "User",
"mobile": "Mobile",
"all_users": "All users", "all_users": "All users",
"generated_at": "Generated at", "generated_at": "Generated at",
"summary": "Summary", "summary": "Summary",
"total_hours": "Total hours", "total_hours": "Total hours",
"billable_hours": "Billable hours", "billable_hours": "Billable hours",
"non_billable_hours": "Non-billable hours", "non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate",
"income": "Income", "income": "Income",
"daily_summary": "Daily Summary", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
@@ -53,12 +55,14 @@ TRANSLATIONS = {
"from_date": "از تاریخ", "from_date": "از تاریخ",
"to_date": "تا تاریخ", "to_date": "تا تاریخ",
"user": "کاربر", "user": "کاربر",
"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": "نرخ ساعتی",
"income": "درآمد", "income": "درآمد",
"daily_summary": "خلاصه روزانه", "daily_summary": "خلاصه روزانه",
"clients": "مشتریان", "clients": "مشتریان",

View File

@@ -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) 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]: def _section_headers(locale: ExportLocale) -> list[str]:
headers = [ headers = [
locale.t("name"), 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("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("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("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(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]))
worksheet.append([]) worksheet.append([])
worksheet.append([locale.t("summary")]) 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): for row_index in range(1, worksheet.max_row + 1):
first_cell = worksheet.cell(row=row_index, column=1) first_cell = worksheet.cell(row=row_index, column=1)
second_cell = worksheet.cell(row=row_index, column=2) 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) _apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
if second_cell.value: 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) _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: 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: if second_cell.value:
_apply_cell_style(second_cell, rtl=locale.is_rtl) _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("billable_hours"),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
locale.t("hourly_rate"),
locale.t("income"), 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["billable_duration"], ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True), locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_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"]), _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"]["billable_duration"], ascii_digits=True),
locale.format_duration(report_data["summary"]["non_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), locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True),
"-",
_money_label_excel(locale, report_data["summary"]["income_totals"]), _money_label_excel(locale, report_data["summary"]["income_totals"]),
], ],
) )
@@ -261,9 +293,10 @@ def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths:
return table 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: 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 [ return [
_rtl_row( _rtl_row(
locale, locale,
@@ -272,6 +305,7 @@ def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]
locale.format_duration(row["billable_duration"]), locale.format_duration(row["billable_duration"]),
locale.format_duration(row["non_billable_duration"]), locale.format_duration(row["non_billable_duration"]),
locale.format_duration(row["total_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"]), _money_label(locale, row["income_totals"]),
], ],
) )
@@ -360,7 +394,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
[locale.t("period"), locale.period_label(scope["period"])], [locale.t("period"), locale.period_label(scope["period"])],
[locale.t("from_date"), locale.format_date(scope["from_date"])], [locale.t("from_date"), locale.format_date(scope["from_date"])],
[locale.t("to_date"), locale.format_date(scope["to_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())], [locale.t("generated_at"), locale.format_date(datetime.now().date())],
] ]
if locale.is_rtl: if locale.is_rtl:
@@ -425,19 +460,31 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes:
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("non_billable_hours"), locale.t("non_billable_hours"),
locale.t("total_hours"), locale.t("total_hours"),
*( [locale.t("hourly_rate")] if is_daily else [] ),
locale.t("income"), locale.t("income"),
], ],
) )
table = _styled_table( table = _styled_table(
[header, *_report_table_rows(locale, rows)], [header, *_report_table_rows(locale, rows, is_daily=is_daily)],
locale=locale, locale=locale,
column_widths=[ 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.26,
doc.width * 0.15, doc.width * 0.15,
doc.width * 0.17, doc.width * 0.17,
doc.width * 0.14, doc.width * 0.14,
doc.width * 0.28, doc.width * 0.28,
], ]
),
) )
story.extend([table, Spacer(1, 5 * mm)]) story.extend([table, Spacer(1, 5 * mm)])

View File

@@ -171,6 +171,53 @@ def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
assert len(workbook.sheetnames) == 3 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): def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create( job = ReportExportJob.objects.create(
requesting_user=owner, requesting_user=owner,

View File

@@ -108,6 +108,48 @@ def test_admin_can_request_combined_table_report(api_client, admin, workspace, t
assert response.status_code == 200 assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "03:00:00" assert response.data["summary"]["total_duration"] == "03:00:00"
assert len(response.data["days"]) == 2 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): def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):