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):
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"]),
}
)

View File

@@ -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": "مشتریان",

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)
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)])