diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py index c41be9a..5528576 100644 --- a/apps/reports/services/export_i18n.py +++ b/apps/reports/services/export_i18n.py @@ -49,9 +49,13 @@ TRANSLATIONS = { "rate_history": "Hourly rate history", "from": "From", "to": "To", + "now": "Now", + "project": "Project", "percentage": "Percentage", "hour_percentage": "Hour %", "income_percentage": "Income %", + "multiple_rates": "Multiple rates - see details", + "variable_rate": "Variable rate", "none": "None", "daily_summary": "Daily Summary", "clients": "Clients", @@ -93,9 +97,13 @@ TRANSLATIONS = { "rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "from": "\u0627\u0632", "to": "\u062a\u0627", + "now": "\u062d\u0627\u0644", + "project": "\u067e\u0631\u0648\u0698\u0647", "percentage": "\u062f\u0631\u0635\u062f", "hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a", "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", "daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647", "clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646", @@ -140,6 +148,8 @@ CURRENCY_LABELS = { "TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"}, } +DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"} + @dataclass(frozen=True) class ExportLocale: @@ -174,6 +184,15 @@ class ExportLocale: return self.format_number(value, ascii_digits=ascii_digits) 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() if not raw: return raw @@ -189,7 +208,11 @@ class ExportLocale: grouped_integer = f"{int(integer_part):,}" formatted = f"{sign}{grouped_integer}" 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: formatted = f"{formatted}.{trimmed_fraction}" return self.format_number(formatted, ascii_digits=ascii_digits) @@ -200,7 +223,9 @@ class ExportLocale: parts = [] for item in income_totals: 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) def currency_label(self, code: str | None) -> str: diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index c65081a..244bda0 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -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: if not rates: - return locale.t("none") + return "-" 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 ] 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: 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'])}" ) @@ -104,16 +104,43 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = 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'])}" + 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: if not rate: 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 +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]: headers = [ locale.t("name"), @@ -227,12 +254,19 @@ def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, asc first_row = rate_periods[0] last_row = rate_periods[-1] + last_to_date = last_row.get("to_date") return ( 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: worksheet.append([]) _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), 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], hour_percentages: list[dict] | None = None, income_percentages: list[dict] | None = None, + financial_only: bool = False, ) -> None: worksheet.append([]) _append_merged_heading( worksheet, locale=locale, 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 headers = [ locale.t("name"), locale.t("billable_hours"), *( [locale.t("hour_percentage")] if hour_percentages is not None else [] ), - locale.t("non_billable_hours"), - locale.t("total_hours"), + *( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ), locale.t("income"), *( [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 else [] ), - locale.format_duration(row["non_billable_duration"], ascii_digits=True), - locale.format_duration(row["total_duration"], ascii_digits=True), + *( + [] + if financial_only + else [ + 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"]), *( [_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]]]: - rate_rows = [ - [ - _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 []) - ] + rate_rows = _summary_rate_rows(locale, summary) client_rows = _summary_breakdown_rows( locale, 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, 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["non_billable_duration"], ascii_digits=True) if index == 0 else None, *(rate_rows[index] if index < len(rate_rows) else [None, 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]), @@ -662,7 +698,7 @@ def _render_all_users_overall_excel_sheet( worksheet, row=15, start_col=1, - end_col=16, + end_col=15, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl, ) @@ -670,7 +706,6 @@ def _render_all_users_overall_excel_sheet( locale.t("name"), locale.t("mobile"), locale.t("working_hours"), - locale.t("non_working_hours"), locale.t("hourly_rate"), locale.t("period"), locale.t("income"), @@ -704,27 +739,27 @@ def _render_all_users_overall_excel_sheet( values=values, 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) rate_rows = user_summary.get("rate_periods") or [] client_rows = user_summary.get("client_percentages") or [] project_rows = user_summary.get("project_percentages") or [] tag_rows = user_summary.get("tag_percentages") or [] 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=6, value_present=True) 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=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: + _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=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: + _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=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 += 2 @@ -765,8 +800,6 @@ def _render_all_users_overall_excel_sheet( locale.t("name"), locale.t("billable_hours"), locale.t("hour_percentage"), - locale.t("non_billable_hours"), - locale.t("total_hours"), locale.t("income"), locale.t("income_percentage"), ], @@ -785,8 +818,6 @@ def _render_all_users_overall_excel_sheet( row["name"], locale.format_duration(row["billable_duration"], 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"]), _percentage_display(locale, income_percentages or [], row, ascii_digits=True), ], @@ -798,7 +829,7 @@ def _render_all_users_overall_excel_sheet( worksheet, row=current_row, 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, ) current_row += 1 @@ -808,25 +839,30 @@ def _render_all_users_overall_excel_sheet( "A": 31.57, "B": 19.86, "C": 18.0, - "D": 17.0, - "E": 18.0, - "F": 26.0, - "G": 24.0, - "H": 28.0, - "I": 14.0, - "J": 16.0, - "K": 28.0, - "L": 14.0, - "M": 16.0, - "N": 24.0, - "O": 14.0, - "P": 16.0, + "D": 18.0, + "E": 26.0, + "F": 24.0, + "G": 28.0, + "H": 14.0, + "I": 16.0, + "J": 28.0, + "K": 14.0, + "L": 16.0, + "M": 24.0, + "N": 14.0, + "O": 16.0, } for column, width in overall_widths.items(): 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: worksheet.sheet_view.rightToLeft = True worksheet.freeze_panes = "E4" @@ -860,6 +896,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) - if user_summary else report_data.get("client_income_percentages") ), + financial_only=financial_only_breakdowns, ) _append_breakdown_table( worksheet, @@ -876,6 +913,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) - if user_summary else report_data.get("project_income_percentages") ), + financial_only=financial_only_breakdowns, ) _append_breakdown_table( worksheet, @@ -892,6 +930,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) - if user_summary else report_data.get("tag_income_percentages") ), + financial_only=financial_only_breakdowns, ) _autosize_columns(worksheet) @@ -935,7 +974,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo used_titles, ) 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) else: 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), locale.format_date(row["from_date"]), - locale.format_date(row["to_date"]), + _rate_to_label(locale, row.get("to_date")), ], ) for row in rows @@ -1056,7 +1100,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width ] if not rows: 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: @@ -1083,6 +1135,7 @@ def _append_pdf_report_sections( doc_width: float, section_style: ParagraphStyle, user_summary: dict | None = None, + financial_only_breakdowns: bool = False, ) -> None: sections = [ ("daily_summary", report_data["days"], True), @@ -1107,8 +1160,7 @@ def _append_pdf_report_sections( locale.t("name"), locale.t("billable_hours"), locale.t("hour_percentage"), - locale.t("non_billable_hours"), - locale.t("total_hours"), + *( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ), locale.t("income"), locale.t("income_percentage"), ] @@ -1129,49 +1181,69 @@ def _append_pdf_report_sections( row["name"], locale.format_duration(row["billable_duration"]), _percentage_display(locale, hour_percentage_rows, row), - locale.format_duration(row["non_billable_duration"]), - locale.format_duration(row["total_duration"]), + *( + [] + if financial_only_breakdowns + else [ + locale.format_duration(row["non_billable_duration"]), + locale.format_duration(row["total_duration"]), + ] + ), _money_label(locale, row["income_totals"]), _percentage_display(locale, income_percentage_rows or [], row), ], ) for row in rows - ] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])] + ] or [ + _rtl_row( + locale, + [locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""], + ) + ] + if is_daily: + column_widths = [ + doc_width * 0.20, + doc_width * 0.12, + doc_width * 0.15, + doc_width * 0.13, + doc_width * 0.16, + doc_width * 0.24, + ] + elif hour_percentage_rows is not None: + fixed_widths = ( + [ + 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.12, + doc_width * 0.12, + doc_width * 0.19, + doc_width * 0.15, + ] + ) + column_widths = [doc_width - sum(fixed_widths), *fixed_widths] + if locale.is_rtl: + column_widths = list(reversed(column_widths)) + else: + fixed_widths = [ + doc_width * 0.15, + doc_width * 0.17, + doc_width * 0.14, + 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=( - [ - doc_width * 0.20, - doc_width * 0.12, - doc_width * 0.15, - doc_width * 0.13, - doc_width * 0.16, - doc_width * 0.24, - ] - if is_daily - else [ - *( - [ - doc_width * 0.20, - doc_width * 0.11, - doc_width * 0.11, - doc_width * 0.12, - doc_width * 0.12, - doc_width * 0.19, - doc_width * 0.15, - ] - if hour_percentage_rows is not None - else [ - doc_width * 0.13, - doc_width * 0.15, - doc_width * 0.17, - doc_width * 0.14, - doc_width * 0.28, - ] - ) - ] - ), + column_widths=column_widths, ) 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("mobile"), locale.t("working_hours"), - locale.t("non_working_hours"), locale.t("hourly_rate"), - locale.t("period"), locale.t("income"), ], ) @@ -1326,9 +1396,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report summary["user"]["name"], locale.format_number(summary["user"]["mobile"]), locale.format_duration(summary["billable_duration"]), - locale.format_duration(summary["non_billable_duration"]), - _rates_label(locale, summary.get("hourly_rates") or []), - _summary_period_label(locale, summary.get("rate_periods") or []), + _pdf_summary_rate_label(locale, summary.get("hourly_rates") or []), _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], locale=locale, column_widths=[ - doc.width * 0.18, - doc.width * 0.13, - doc.width * 0.13, - doc.width * 0.13, - doc.width * 0.13, + doc.width * 0.25, 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, section_style=section_style, user_summary=user_report.get("user_summary"), + financial_only_breakdowns=True, ) else: _append_pdf_report_sections( @@ -1388,6 +1455,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report doc_width=doc.width, section_style=section_style, user_summary=report_data.get("user_summary"), + financial_only_breakdowns=False, ) doc.build(story) diff --git a/apps/reports/tests/test_exporters.py b/apps/reports/tests/test_exporters.py index ebe1a93..1b65f2e 100644 --- a/apps/reports/tests/test_exporters.py +++ b/apps/reports/tests/test_exporters.py @@ -4,7 +4,12 @@ from django.test import TestCase from openpyxl import load_workbook 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): @@ -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): + 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): locale = build_export_locale("en") report_data = make_report_data( hourly_rate={"amount": "15.00", "currency": "USD"}, ) 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"), ] per_user_reports = [ @@ -91,7 +149,7 @@ class ReportExporterTests(TestCase): mobile="09129990001", 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( @@ -123,14 +181,13 @@ class ReportExporterTests(TestCase): self.assertEqual(summary_sheet["A1"].value, "Workspace Report") self.assertEqual(summary_sheet["B1"].value, "Exports") 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( - 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", "Mobile", "Working hours", - "Non-working hours", "Hourly rate", "Period", "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 "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_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 %") self.assertEqual( - breakdown_header, + breakdown_header[:5], ( "Name", "Billable hours", "Hour %", - "Non-billable hours", - "Total hours", "Income", "Income %", ),