feat(reports): add daily rate to report tables and exports
This commit is contained in:
@@ -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"]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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": "مشتریان",
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user