fix(reports): refine financial export summaries
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-23 20:13:35 +03:30
parent 59cf62bc73
commit 22e08a099c
3 changed files with 260 additions and 111 deletions

View File

@@ -49,9 +49,13 @@ TRANSLATIONS = {
"rate_history": "Hourly rate history", "rate_history": "Hourly rate history",
"from": "From", "from": "From",
"to": "To", "to": "To",
"now": "Now",
"project": "Project",
"percentage": "Percentage", "percentage": "Percentage",
"hour_percentage": "Hour %", "hour_percentage": "Hour %",
"income_percentage": "Income %", "income_percentage": "Income %",
"multiple_rates": "Multiple rates - see details",
"variable_rate": "Variable rate",
"none": "None", "none": "None",
"daily_summary": "Daily Summary", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
@@ -93,9 +97,13 @@ TRANSLATIONS = {
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"from": "\u0627\u0632", "from": "\u0627\u0632",
"to": "\u062a\u0627", "to": "\u062a\u0627",
"now": "\u062d\u0627\u0644",
"project": "\u067e\u0631\u0648\u0698\u0647",
"percentage": "\u062f\u0631\u0635\u062f", "percentage": "\u062f\u0631\u0635\u062f",
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a", "hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f", "income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
"multiple_rates": "\u0686\u0646\u062f \u0646\u0631\u062e - \u062c\u0632\u0626\u06cc\u0627\u062a \u062f\u0631 \u06af\u0632\u0627\u0631\u0634 \u06a9\u0627\u0631\u0628\u0631",
"variable_rate": "\u0646\u0631\u062e \u0645\u062a\u063a\u06cc\u0631",
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f", "none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647", "daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646", "clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
@@ -140,6 +148,8 @@ CURRENCY_LABELS = {
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"}, "TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
} }
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
@dataclass(frozen=True) @dataclass(frozen=True)
class ExportLocale: class ExportLocale:
@@ -174,6 +184,15 @@ class ExportLocale:
return self.format_number(value, ascii_digits=ascii_digits) return self.format_number(value, ascii_digits=ascii_digits)
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str: def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
def format_amount_for_currency(
self,
value: object,
currency: str | None,
*,
ascii_digits: bool = False,
) -> str:
raw = str(value).strip() raw = str(value).strip()
if not raw: if not raw:
return raw return raw
@@ -189,7 +208,11 @@ class ExportLocale:
grouped_integer = f"{int(integer_part):,}" grouped_integer = f"{int(integer_part):,}"
formatted = f"{sign}{grouped_integer}" formatted = f"{sign}{grouped_integer}"
if fractional_part: if fractional_part:
trimmed_fraction = fractional_part.rstrip("0") trimmed_fraction = (
""
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
else fractional_part.rstrip("0")
)
if trimmed_fraction: if trimmed_fraction:
formatted = f"{formatted}.{trimmed_fraction}" formatted = f"{formatted}.{trimmed_fraction}"
return self.format_number(formatted, ascii_digits=ascii_digits) return self.format_number(formatted, ascii_digits=ascii_digits)
@@ -200,7 +223,9 @@ class ExportLocale:
parts = [] parts = []
for item in income_totals: for item in income_totals:
currency = self.currency_label(item["currency"]) currency = self.currency_label(item["currency"])
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}") parts.append(
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
)
return " | ".join(parts) return " | ".join(parts)
def currency_label(self, code: str | None) -> str: def currency_label(self, code: str | None) -> str:

View File

@@ -76,9 +76,9 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
if not rates: if not rates:
return locale.t("none") return "-"
items = [ items = [
f"{locale.format_amount(rate['amount'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}" f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
for rate in rates for rate in rates
] ]
return ", ".join(items) return ", ".join(items)
@@ -96,7 +96,7 @@ def _percentages_label(locale: ExportLocale, rows: list[dict], *, ascii_digits:
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str: def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
return ( return (
f"{locale.format_amount(row['amount'], ascii_digits=ascii_digits)} " f"{locale.format_amount_for_currency(row['amount'], row['currency'], ascii_digits=ascii_digits)} "
f"{locale.currency_label(row['currency'])}" f"{locale.currency_label(row['currency'])}"
) )
@@ -104,16 +104,43 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
def _rate_label(locale: ExportLocale, rate: dict | None) -> str: def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate: if not rate:
return "-" return "-"
return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}" 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: def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
if not rate: if not rate:
return "-" return "-"
value = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" value = (
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
f"{locale.currency_label(rate['currency'])}"
)
return f"\u202B{value}\u202C" if locale.is_rtl else value return f"\u202B{value}\u202C" if locale.is_rtl else value
def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str:
if len(rates) > 1:
return locale.t("variable_rate")
return _rates_label(locale, rates)
def _summary_rate_rows(locale: ExportLocale, summary: dict) -> list[list[str]]:
rate_periods = summary.get("rate_periods") or []
if not rate_periods:
return [[locale.t("none"), locale.t("none")]]
if len(rate_periods) > 1:
return [[locale.t("variable_rate"), _summary_period_label(locale, rate_periods, ascii_digits=True)]]
row = rate_periods[0]
return [
[
_rate_period_label(locale, row, ascii_digits=True),
(
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
),
]
]
def _section_headers(locale: ExportLocale) -> list[str]: def _section_headers(locale: ExportLocale) -> list[str]:
headers = [ headers = [
locale.t("name"), locale.t("name"),
@@ -227,12 +254,19 @@ def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, asc
first_row = rate_periods[0] first_row = rate_periods[0]
last_row = rate_periods[-1] last_row = rate_periods[-1]
last_to_date = last_row.get("to_date")
return ( return (
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - " f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
f"{locale.format_date(last_row['to_date'], ascii_digits=ascii_digits)}" f"{(_rate_to_label(locale, last_to_date, ascii_digits=ascii_digits))}"
) )
def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: bool = False) -> str:
if not to_date:
return locale.t("now")
return locale.format_date(to_date, ascii_digits=ascii_digits)
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
worksheet.append([]) 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=3)
@@ -257,7 +291,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
[ [
_rate_period_label(locale, row, ascii_digits=True), _rate_period_label(locale, row, ascii_digits=True),
locale.format_date(row["from_date"], ascii_digits=True), locale.format_date(row["from_date"], ascii_digits=True),
locale.format_date(row["to_date"], ascii_digits=True), _rate_to_label(locale, row.get("to_date"), ascii_digits=True),
], ],
) )
) )
@@ -441,21 +475,27 @@ def _append_breakdown_table(
rows: list[dict], rows: list[dict],
hour_percentages: list[dict] | None = None, hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None, income_percentages: list[dict] | None = None,
financial_only: bool = False,
) -> None: ) -> None:
worksheet.append([]) worksheet.append([])
_append_merged_heading( _append_merged_heading(
worksheet, worksheet,
locale=locale, locale=locale,
title=locale.t(title_key), title=locale.t(title_key),
span=7 if hour_percentages is not None else 5, span=(
5
if hour_percentages is not None and financial_only
else 7
if hour_percentages is not None
else 5
),
) )
header_row = worksheet.max_row + 1 header_row = worksheet.max_row + 1
headers = [ headers = [
locale.t("name"), locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ), *( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
locale.t("non_billable_hours"), *( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("total_hours"),
locale.t("income"), locale.t("income"),
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ), *( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
] ]
@@ -477,8 +517,14 @@ def _append_breakdown_table(
if hour_percentages is not None if hour_percentages is not None
else [] else []
), ),
*(
[]
if financial_only
else [
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),
]
),
_money_label_excel(locale, row["income_totals"]), _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)]
@@ -562,16 +608,7 @@ def _write_table_row(
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]: def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
rate_rows = [ rate_rows = _summary_rate_rows(locale, summary)
[
_rate_period_label(locale, row, ascii_digits=True),
(
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
),
]
for row in (summary.get("rate_periods") or [])
]
client_rows = _summary_breakdown_rows( client_rows = _summary_breakdown_rows(
locale, locale,
summary.get("client_percentages") or [], summary.get("client_percentages") or [],
@@ -595,7 +632,6 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
summary["user"]["name"] if index == 0 else None, summary["user"]["name"] if index == 0 else None,
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None, locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None, locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, None]), *(rate_rows[index] if index < len(rate_rows) else [None, None]),
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None, _money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
*(client_rows[index] if index < len(client_rows) else [None, None, None]), *(client_rows[index] if index < len(client_rows) else [None, None, None]),
@@ -662,7 +698,7 @@ def _render_all_users_overall_excel_sheet(
worksheet, worksheet,
row=15, row=15,
start_col=1, start_col=1,
end_col=16, end_col=15,
value=locale.t("users_summary_sheet"), value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
@@ -670,7 +706,6 @@ def _render_all_users_overall_excel_sheet(
locale.t("name"), locale.t("name"),
locale.t("mobile"), locale.t("mobile"),
locale.t("working_hours"), locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"), locale.t("hourly_rate"),
locale.t("period"), locale.t("period"),
locale.t("income"), locale.t("income"),
@@ -704,27 +739,27 @@ def _render_all_users_overall_excel_sheet(
values=values, values=values,
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
for column in (1, 2, 3, 4, 7): for column in (1, 2, 3, 6):
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
rate_rows = user_summary.get("rate_periods") or [] rate_rows = user_summary.get("rate_periods") or []
client_rows = user_summary.get("client_percentages") or [] client_rows = user_summary.get("client_percentages") or []
project_rows = user_summary.get("project_percentages") or [] project_rows = user_summary.get("project_percentages") or []
tag_rows = user_summary.get("tag_percentages") or [] tag_rows = user_summary.get("tag_percentages") or []
if len(rate_rows) == 1: if len(rate_rows) == 1:
_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) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True)
if len(client_rows) == 1: 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=8, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
if len(project_rows) == 1: 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) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
if len(tag_rows) == 1: 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=14, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
current_row += span current_row += span
current_row += 2 current_row += 2
@@ -765,8 +800,6 @@ def _render_all_users_overall_excel_sheet(
locale.t("name"), locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("hour_percentage"), locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"), locale.t("income"),
locale.t("income_percentage"), locale.t("income_percentage"),
], ],
@@ -785,8 +818,6 @@ def _render_all_users_overall_excel_sheet(
row["name"], row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True), _percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
locale.format_duration(row["total_duration"], ascii_digits=True),
_money_label_excel(locale, row["income_totals"]), _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),
], ],
@@ -798,7 +829,7 @@ def _render_all_users_overall_excel_sheet(
worksheet, worksheet,
row=current_row, row=current_row,
start_col=1, start_col=1,
values=[locale.t("no_data"), None, None, None, None, None, None], values=[locale.t("no_data"), None, None, None, None],
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
current_row += 1 current_row += 1
@@ -808,25 +839,30 @@ def _render_all_users_overall_excel_sheet(
"A": 31.57, "A": 31.57,
"B": 19.86, "B": 19.86,
"C": 18.0, "C": 18.0,
"D": 17.0, "D": 18.0,
"E": 18.0, "E": 26.0,
"F": 26.0, "F": 24.0,
"G": 24.0, "G": 28.0,
"H": 28.0, "H": 14.0,
"I": 14.0, "I": 16.0,
"J": 16.0, "J": 28.0,
"K": 28.0, "K": 14.0,
"L": 14.0, "L": 16.0,
"M": 16.0, "M": 24.0,
"N": 24.0, "N": 14.0,
"O": 14.0, "O": 16.0,
"P": 16.0,
} }
for column, width in overall_widths.items(): for column, width in overall_widths.items():
worksheet.column_dimensions[column].width = width worksheet.column_dimensions[column].width = width
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None: def _render_excel_sheet(
worksheet,
*,
locale: ExportLocale,
report_data: dict,
financial_only_breakdowns: bool = False,
) -> None:
if locale.is_rtl: if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4" worksheet.freeze_panes = "E4"
@@ -860,6 +896,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary if user_summary
else report_data.get("client_income_percentages") else report_data.get("client_income_percentages")
), ),
financial_only=financial_only_breakdowns,
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
@@ -876,6 +913,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary if user_summary
else report_data.get("project_income_percentages") else report_data.get("project_income_percentages")
), ),
financial_only=financial_only_breakdowns,
) )
_append_breakdown_table( _append_breakdown_table(
worksheet, worksheet,
@@ -892,6 +930,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary if user_summary
else report_data.get("tag_income_percentages") else report_data.get("tag_income_percentages")
), ),
financial_only=financial_only_breakdowns,
) )
_autosize_columns(worksheet) _autosize_columns(worksheet)
@@ -935,7 +974,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
used_titles, used_titles,
) )
worksheet = workbook.create_sheet(title=user_title) worksheet = workbook.create_sheet(title=user_title)
_render_excel_sheet(worksheet, locale=locale, report_data=user_report) _render_excel_sheet(
worksheet,
locale=locale,
report_data=user_report,
financial_only_breakdowns=True,
)
used_titles.add(user_title) used_titles.add(user_title)
else: else:
overall_sheet = workbook.active overall_sheet = workbook.active
@@ -1048,7 +1092,7 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
[ [
_rate_period_label(locale, row), _rate_period_label(locale, row),
locale.format_date(row["from_date"]), locale.format_date(row["from_date"]),
locale.format_date(row["to_date"]), _rate_to_label(locale, row.get("to_date")),
], ],
) )
for row in rows for row in rows
@@ -1056,7 +1100,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
] ]
if not rows: if not rows:
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""])) data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
return _styled_table(data, locale=locale, column_widths=[doc_width * 0.34, doc_width * 0.33, doc_width * 0.33]) fixed_widths = [doc_width * 0.18, doc_width * 0.18]
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl:
column_widths = list(reversed(column_widths))
return _styled_table(
data,
locale=locale,
column_widths=column_widths,
)
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table: def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
@@ -1083,6 +1135,7 @@ def _append_pdf_report_sections(
doc_width: float, doc_width: float,
section_style: ParagraphStyle, section_style: ParagraphStyle,
user_summary: dict | None = None, user_summary: dict | None = None,
financial_only_breakdowns: bool = False,
) -> None: ) -> None:
sections = [ sections = [
("daily_summary", report_data["days"], True), ("daily_summary", report_data["days"], True),
@@ -1107,8 +1160,7 @@ def _append_pdf_report_sections(
locale.t("name"), locale.t("name"),
locale.t("billable_hours"), locale.t("billable_hours"),
locale.t("hour_percentage"), locale.t("hour_percentage"),
locale.t("non_billable_hours"), *( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("total_hours"),
locale.t("income"), locale.t("income"),
locale.t("income_percentage"), locale.t("income_percentage"),
] ]
@@ -1129,19 +1181,27 @@ def _append_pdf_report_sections(
row["name"], row["name"],
locale.format_duration(row["billable_duration"]), locale.format_duration(row["billable_duration"]),
_percentage_display(locale, hour_percentage_rows, row), _percentage_display(locale, hour_percentage_rows, row),
*(
[]
if financial_only_breakdowns
else [
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"]),
]
),
_money_label(locale, row["income_totals"]), _money_label(locale, row["income_totals"]),
_percentage_display(locale, income_percentage_rows or [], row), _percentage_display(locale, income_percentage_rows or [], row),
], ],
) )
for row in rows for row in rows
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])] ] or [
table = _styled_table( _rtl_row(
[header, *body_rows], locale,
locale=locale, [locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""],
column_widths=( )
[ ]
if is_daily:
column_widths = [
doc_width * 0.20, doc_width * 0.20,
doc_width * 0.12, doc_width * 0.12,
doc_width * 0.15, doc_width * 0.15,
@@ -1149,11 +1209,16 @@ def _append_pdf_report_sections(
doc_width * 0.16, doc_width * 0.16,
doc_width * 0.24, doc_width * 0.24,
] ]
if is_daily elif hour_percentage_rows is not None:
else [ fixed_widths = (
*(
[ [
doc_width * 0.20, doc_width * 0.11,
doc_width * 0.11,
doc_width * 0.19,
doc_width * 0.15,
]
if financial_only_breakdowns
else [
doc_width * 0.11, doc_width * 0.11,
doc_width * 0.11, doc_width * 0.11,
doc_width * 0.12, doc_width * 0.12,
@@ -1161,17 +1226,24 @@ def _append_pdf_report_sections(
doc_width * 0.19, doc_width * 0.19,
doc_width * 0.15, doc_width * 0.15,
] ]
if hour_percentage_rows is not None )
else [ column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
doc_width * 0.13, if locale.is_rtl:
column_widths = list(reversed(column_widths))
else:
fixed_widths = [
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,
] ]
) column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
] if locale.is_rtl:
), column_widths = list(reversed(column_widths))
table = _styled_table(
[header, *body_rows],
locale=locale,
column_widths=column_widths,
) )
story.extend([table, Spacer(1, 5 * mm)]) story.extend([table, Spacer(1, 5 * mm)])
@@ -1313,9 +1385,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
locale.t("name"), locale.t("name"),
locale.t("mobile"), locale.t("mobile"),
locale.t("working_hours"), locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"), locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"), locale.t("income"),
], ],
) )
@@ -1326,9 +1396,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
summary["user"]["name"], summary["user"]["name"],
locale.format_number(summary["user"]["mobile"]), locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]), locale.format_duration(summary["billable_duration"]),
locale.format_duration(summary["non_billable_duration"]), _pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
_rates_label(locale, summary.get("hourly_rates") or []),
_summary_period_label(locale, summary.get("rate_periods") or []),
_money_label(locale, summary["income_totals"]), _money_label(locale, summary["income_totals"]),
], ],
) )
@@ -1339,13 +1407,11 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
[user_summary_header, *user_summary_rows], [user_summary_header, *user_summary_rows],
locale=locale, locale=locale,
column_widths=[ column_widths=[
doc.width * 0.18, doc.width * 0.25,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.16, doc.width * 0.16,
doc.width * 0.14, doc.width * 0.16,
doc.width * 0.19,
doc.width * 0.24,
], ],
) )
) )
@@ -1379,6 +1445,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
doc_width=doc.width, doc_width=doc.width,
section_style=section_style, section_style=section_style,
user_summary=user_report.get("user_summary"), user_summary=user_report.get("user_summary"),
financial_only_breakdowns=True,
) )
else: else:
_append_pdf_report_sections( _append_pdf_report_sections(
@@ -1388,6 +1455,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
doc_width=doc.width, doc_width=doc.width,
section_style=section_style, section_style=section_style,
user_summary=report_data.get("user_summary"), user_summary=report_data.get("user_summary"),
financial_only_breakdowns=False,
) )
doc.build(story) doc.build(story)

View File

@@ -4,7 +4,12 @@ from django.test import TestCase
from openpyxl import load_workbook from openpyxl import load_workbook
from apps.reports.services.export_i18n import build_export_locale from apps.reports.services.export_i18n import build_export_locale
from apps.reports.services.exporters import build_excel_report, build_pdf_report from apps.reports.services.exporters import (
_pdf_summary_rate_label,
_rate_label,
build_excel_report,
build_pdf_report,
)
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None): def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
@@ -74,14 +79,67 @@ def make_user_summary(*, name: str, mobile: str):
} }
def make_variable_user_summary(*, name: str, mobile: str):
summary = make_user_summary(name=name, mobile=mobile)
summary["hourly_rates"] = [
{"amount": "15.00", "currency": "USD"},
{"amount": "18.00", "currency": "USD"},
]
summary["rate_periods"] = [
{
"amount": "15.00",
"currency": "USD",
"from_date": "2026-04-01",
"to_date": "2026-04-14",
},
{
"amount": "18.00",
"currency": "USD",
"from_date": "2026-04-15",
"to_date": "2026-04-30",
},
]
return summary
class ReportExporterTests(TestCase): class ReportExporterTests(TestCase):
def test_export_rate_labels_trim_rial_and_toman_decimals(self):
locale = build_export_locale("en")
self.assertEqual(
_rate_label(locale, {"amount": "1250.75", "currency": "USD"}),
"1,250.75 USD",
)
self.assertEqual(
_rate_label(locale, {"amount": "1250.75", "currency": "IRR"}),
"1,250 IRR",
)
self.assertEqual(
_rate_label(locale, {"amount": "9800.50", "currency": "IRT"}),
"9,800 IRT",
)
def test_pdf_summary_uses_multiple_rates_label(self):
locale = build_export_locale("en")
self.assertEqual(
_pdf_summary_rate_label(
locale,
[
{"amount": "15.00", "currency": "USD"},
{"amount": "18.00", "currency": "USD"},
],
),
"Variable rate",
)
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self): def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en") locale = build_export_locale("en")
report_data = make_report_data( report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"}, hourly_rate={"amount": "15.00", "currency": "USD"},
) )
report_data["user_summaries"] = [ report_data["user_summaries"] = [
make_user_summary(name="Owner User", mobile="09129990001"), make_variable_user_summary(name="Owner User", mobile="09129990001"),
make_user_summary(name="Team Mate", mobile="09129990002"), make_user_summary(name="Team Mate", mobile="09129990002"),
] ]
per_user_reports = [ per_user_reports = [
@@ -91,7 +149,7 @@ class ReportExporterTests(TestCase):
mobile="09129990001", mobile="09129990001",
hourly_rate={"amount": "15.00", "currency": "USD"}, hourly_rate={"amount": "15.00", "currency": "USD"},
), ),
"user_summary": make_user_summary(name="Owner User", mobile="09129990001"), "user_summary": make_variable_user_summary(name="Owner User", mobile="09129990001"),
}, },
{ {
**make_report_data( **make_report_data(
@@ -123,14 +181,13 @@ class ReportExporterTests(TestCase):
self.assertEqual(summary_sheet["A1"].value, "Workspace Report") self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports") self.assertEqual(summary_sheet["B1"].value, "Exports")
self.assertEqual(summary_sheet["A15"].value, "Users Summary") self.assertEqual(summary_sheet["A15"].value, "Users Summary")
self.assertIn("A15:P15", {str(item) for item in summary_sheet.merged_cells.ranges}) self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges})
self.assertEqual( self.assertEqual(
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16], tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15],
( (
"Name", "Name",
"Mobile", "Mobile",
"Working hours", "Working hours",
"Non-working hours",
"Hourly rate", "Hourly rate",
"Period", "Period",
"Income", "Income",
@@ -147,6 +204,7 @@ class ReportExporterTests(TestCase):
) )
self.assertTrue(any(row and "Owner User" in row for row in summary_values)) self.assertTrue(any(row and "Owner User" in row for row in summary_values))
self.assertTrue(any(row and "09129990001" in row for row in summary_values)) self.assertTrue(any(row and "09129990001" in row for row in summary_values))
self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
user_sheet = workbook[workbook.sheetnames[1]] user_sheet = workbook[workbook.sheetnames[1]]
user_values = list(user_sheet.iter_rows(values_only=True)) user_values = list(user_sheet.iter_rows(values_only=True))
@@ -169,13 +227,11 @@ class ReportExporterTests(TestCase):
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %") breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
self.assertEqual( self.assertEqual(
breakdown_header, breakdown_header[:5],
( (
"Name", "Name",
"Billable hours", "Billable hours",
"Hour %", "Hour %",
"Non-billable hours",
"Total hours",
"Income", "Income",
"Income %", "Income %",
), ),