diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py index b3b090c..276362f 100644 --- a/apps/reports/services/aggregation.py +++ b/apps/reports/services/aggregation.py @@ -1,10 +1,10 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Iterable from dataclasses import dataclass, replace from datetime import date, datetime, time, timedelta -from decimal import Decimal, ROUND_HALF_UP -from typing import Iterable +from decimal import ROUND_DOWN, Decimal import jdatetime from django.contrib.auth import get_user_model @@ -39,6 +39,25 @@ ALLOWED_PERIODS = { PERIOD_CUSTOM, } +UNCATEGORIZED_IDS = { + "clients": "__uncategorized_client__", + "projects": "__uncategorized_project__", + "tags": "__uncategorized_tag__", +} + +UNCATEGORIZED_LABELS = { + "en": { + "clients": "No client", + "projects": "No project", + "tags": "No tag", + }, + "fa": { + "clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc", + "projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647", + "tags": "\u0628\u062f\u0648\u0646 \u062a\u06af", + }, +} + def _start_of_week(local_date: date) -> date: days_since_sunday = (local_date.weekday() + 1) % 7 @@ -157,62 +176,209 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]: return periods -def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]: - if total_seconds <= 0: - return [] - rows = [] - for bucket in shares.values(): - percentage = ( - Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds) - ).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - rows.append( - { - "id": bucket["id"], - "name": bucket["name"], - "percentage": f"{percentage}", - } - ) - rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower())) - return rows +def _uncategorized_label(kind: str, language: str) -> str: + resolved_language = language if language in UNCATEGORIZED_LABELS else "en" + return UNCATEGORIZED_LABELS[resolved_language][kind] -def _build_user_summary(user, entries: list[TimeEntry]) -> dict: - summary = _summary_from_entries(entries) - project_shares: dict[str, dict] = {} - client_shares: dict[str, dict] = {} - tag_shares: dict[str, dict] = {} +def _share_bucket(bucket_id: str, name: str) -> dict: + return { + "id": bucket_id, + "name": name, + "seconds": Decimal("0"), + "income": _money_map(), + } - total_seconds = summary["billable_seconds"] + +def _entry_income_payload(entry: TimeEntry) -> tuple[str, Decimal] | None: + if not entry.is_billable or not entry.hourly_rate: + return None + + duration_seconds = get_entry_duration_seconds(entry) + if duration_seconds <= 0: + return None + + hourly_rate = Decimal(entry.hourly_rate) + income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01")) + return entry.currency or "USD", income + + +def _add_money(bucket: defaultdict[str, Decimal], currency: str, amount: Decimal) -> None: + bucket[currency] += amount + + +def _breakdown_targets(entry: TimeEntry, kind: str, language: str) -> list[tuple[str, str]]: + if kind == "clients": + if entry.project and entry.project.client: + return [(str(entry.project.client_id), entry.project.client.name)] + return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))] + + if kind == "projects": + if entry.project: + return [(str(entry.project_id), entry.project.name)] + return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))] + + tags = list(entry.tags.all()) + if tags: + return [(str(tag.id), tag.name) for tag in tags] + return [(UNCATEGORIZED_IDS[kind], _uncategorized_label(kind, language))] + + +def _accumulate_breakdown_shares(entries: list[TimeEntry], kind: str, *, language: str) -> dict[str, dict]: + shares: dict[str, dict] = {} for entry in entries: if not entry.is_billable: continue + duration_seconds = get_entry_duration_seconds(entry) if duration_seconds <= 0: continue - if entry.project_id: - project_bucket = project_shares.setdefault( - str(entry.project_id), - {"id": str(entry.project_id), "name": entry.project.name, "seconds": 0}, - ) - project_bucket["seconds"] += duration_seconds + targets = _breakdown_targets(entry, kind, language) + divisor = Decimal(len(targets)) if kind == "tags" and targets else Decimal("1") + income_payload = _entry_income_payload(entry) - if entry.project and entry.project.client_id: - client_bucket = client_shares.setdefault( - str(entry.project.client_id), - {"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0}, - ) - client_bucket["seconds"] += duration_seconds + for bucket_id, bucket_name in targets: + bucket = shares.setdefault(bucket_id, _share_bucket(bucket_id, bucket_name)) + bucket["seconds"] += Decimal(duration_seconds) / divisor + if income_payload: + currency, amount = income_payload + _add_money(bucket["income"], currency, amount / divisor) - tags = list(entry.tags.all()) - if tags: - allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags)) - for tag in tags: - tag_bucket = tag_shares.setdefault( - str(tag.id), - {"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")}, - ) - tag_bucket["seconds"] += allocated_seconds + return shares + + +def _allocate_percentage_rows(items: list[dict], total_value: Decimal) -> list[dict]: + if total_value <= 0 or not items: + return [] + + working_rows: list[dict] = [] + assigned_total = 0 + for item in items: + value = Decimal(item["value"]) + raw_percentage = (value * Decimal("100") / total_value) if value > 0 else Decimal("0") + floored_percentage = int(raw_percentage.quantize(Decimal("1"), rounding=ROUND_DOWN)) + assigned_total += floored_percentage + working_rows.append( + { + "id": item["id"], + "name": item["name"], + "value": value, + "percentage": floored_percentage, + "remainder": raw_percentage - Decimal(floored_percentage), + } + ) + + remaining_points = max(0, 100 - assigned_total) + for row in sorted( + working_rows, + key=lambda item: (-item["remainder"], -item["value"], item["name"].lower(), item["id"]), + )[:remaining_points]: + row["percentage"] += 1 + + serialized = [ + { + "id": row["id"], + "name": row["name"], + "percentage": str(row["percentage"]), + } + for row in working_rows + ] + serialized.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower())) + return serialized + + +def _single_currency_amount(income_totals: list[dict]) -> tuple[str | None, Decimal] | None: + non_zero_totals: list[tuple[str, Decimal]] = [] + for item in income_totals: + amount = Decimal(item["amount"]) + if amount == 0: + continue + non_zero_totals.append((item["currency"], amount)) + + if not non_zero_totals: + return None, Decimal("0") + + currencies = {currency for currency, _ in non_zero_totals} + if len(currencies) != 1: + return None + + currency = non_zero_totals[0][0] + total_amount = sum((amount for _, amount in non_zero_totals), Decimal("0")) + return currency, total_amount + + +def _complete_percentage_rows( + rows: list[dict], + percentage_rows: list[dict], + *, + unavailable: bool = False, +) -> list[dict]: + if unavailable: + return [] + + existing_ids = {row["id"] for row in percentage_rows} + completed = percentage_rows + [ + {"id": row["id"], "name": row["name"], "percentage": "0"} + for row in rows + if row["id"] not in existing_ids + ] + completed.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower())) + return completed + + +def _serialize_time_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]: + items = [ + { + "id": bucket["id"], + "name": bucket["name"], + "value": Decimal(bucket["seconds"]), + } + for bucket in shares.values() + ] + total_seconds = sum((item["value"] for item in items), Decimal("0")) + return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_seconds)) + + +def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict]) -> list[dict]: + items: list[dict] = [] + currencies: set[str] = set() + + for bucket in shares.values(): + income_totals = _serialize_money_totals(bucket["income"]) + currency_amount = _single_currency_amount(income_totals) + if currency_amount is None: + return [] + + currency, amount = currency_amount + if currency: + currencies.add(currency) + items.append( + { + "id": bucket["id"], + "name": bucket["name"], + "value": amount, + } + ) + + if len(currencies) > 1: + return [] + + total_income = sum((item["value"] for item in items), Decimal("0")) + if total_income <= 0: + return [] + + return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income)) + + +def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict: + summary = _summary_from_entries(entries) + project_rows = _build_breakdown(entries, "projects", language=language) + client_rows = _build_breakdown(entries, "clients", language=language) + tag_rows = _build_breakdown(entries, "tags", language=language) + project_shares = _accumulate_breakdown_shares(entries, "projects", language=language) + client_shares = _accumulate_breakdown_shares(entries, "clients", language=language) + tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language) return { "user": { @@ -222,71 +388,53 @@ def _build_user_summary(user, entries: list[TimeEntry]) -> dict: }, "hourly_rates": _serialize_distinct_rates(entries), "rate_periods": _serialize_rate_periods(entries), - "total_seconds": total_seconds, + "total_seconds": summary["billable_seconds"], "total_duration": summary["total_duration"], "billable_seconds": summary["billable_seconds"], "billable_duration": summary["billable_duration"], "non_billable_seconds": summary["non_billable_seconds"], "non_billable_duration": summary["non_billable_duration"], "income_totals": summary["income_totals"], - "project_percentages": _serialize_percentage_rows(project_shares, total_seconds), - "client_percentages": _serialize_percentage_rows(client_shares, total_seconds), - "tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), + "project_percentages": _serialize_time_percentage_rows(project_rows, project_shares), + "client_percentages": _serialize_time_percentage_rows(client_rows, client_shares), + "tag_percentages": _serialize_time_percentage_rows(tag_rows, tag_shares), + "project_income_percentages": _serialize_income_percentage_rows(project_rows, project_shares), + "client_income_percentages": _serialize_income_percentage_rows(client_rows, client_shares), + "tag_income_percentages": _serialize_income_percentage_rows(tag_rows, tag_shares), } -def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]: +def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]: grouped: dict[str, list[TimeEntry]] = defaultdict(list) for entry in entries: grouped[str(entry.user_id)].append(entry) - summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries] + summaries = [ + _build_user_summary(grouped_entries[0].user, grouped_entries, language=language) + for grouped_entries in grouped.values() + if grouped_entries + ] summaries.sort(key=lambda item: item["user"]["name"].lower()) return summaries -def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict: - project_shares: dict[str, dict] = {} - client_shares: dict[str, dict] = {} - tag_shares: dict[str, dict] = {} - total_seconds = 0 - - for entry in entries: - if not entry.is_billable: - continue - duration_seconds = get_entry_duration_seconds(entry) - if duration_seconds <= 0: - continue - total_seconds += duration_seconds - - if entry.project_id: - project_bucket = project_shares.setdefault( - str(entry.project_id), - {"id": str(entry.project_id), "name": entry.project.name, "seconds": 0}, - ) - project_bucket["seconds"] += duration_seconds - - if entry.project and entry.project.client_id: - client_bucket = client_shares.setdefault( - str(entry.project.client_id), - {"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0}, - ) - client_bucket["seconds"] += duration_seconds - - tags = list(entry.tags.all()) - if tags: - allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags)) - for tag in tags: - tag_bucket = tag_shares.setdefault( - str(tag.id), - {"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")}, - ) - tag_bucket["seconds"] += allocated_seconds +def _build_overall_percentage_payload( + entries: list[TimeEntry], + *, + language: str, + rows_by_kind: dict[str, list[dict]], +) -> dict: + project_shares = _accumulate_breakdown_shares(entries, "projects", language=language) + client_shares = _accumulate_breakdown_shares(entries, "clients", language=language) + tag_shares = _accumulate_breakdown_shares(entries, "tags", language=language) return { - "project_percentages": _serialize_percentage_rows(project_shares, total_seconds), - "client_percentages": _serialize_percentage_rows(client_shares, total_seconds), - "tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), + "project_percentages": _serialize_time_percentage_rows(rows_by_kind["projects"], project_shares), + "client_percentages": _serialize_time_percentage_rows(rows_by_kind["clients"], client_shares), + "tag_percentages": _serialize_time_percentage_rows(rows_by_kind["tags"], tag_shares), + "project_income_percentages": _serialize_income_percentage_rows(rows_by_kind["projects"], project_shares), + "client_income_percentages": _serialize_income_percentage_rows(rows_by_kind["clients"], client_shares), + "tag_income_percentages": _serialize_income_percentage_rows(rows_by_kind["tags"], tag_shares), } @@ -344,7 +492,13 @@ class ReportFilterSerializer(serializers.Serializer): language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en") -def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]: +def _resolve_period_bounds( + period: str, + from_date: date | None, + to_date: date | None, + *, + language: str, +) -> tuple[date, date]: today = timezone.localdate() if language == "fa": today_jalali = jdatetime.date.fromgregorian(date=today) @@ -412,7 +566,11 @@ def load_report_filters(actor, raw_data) -> ReportFilters: "user": raw_data.get("user"), "client": raw_data.get("client"), "project": raw_data.get("project"), - "tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"), + "tags": ( + raw_data.get("tags") or raw_data.getlist("tags") + if hasattr(raw_data, "getlist") + else raw_data.get("tags") + ), "language": raw_data.get("language", "en"), } if normalized["tags"] and not isinstance(normalized["tags"], list): @@ -476,8 +634,15 @@ def load_report_filters(actor, raw_data) -> ReportFilters: def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]: - start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone()) - end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone()) + current_timezone = timezone.get_current_timezone() + start_dt = timezone.make_aware( + datetime.combine(filters.from_date, time.min), + current_timezone, + ) + end_dt = timezone.make_aware( + datetime.combine(filters.to_date + timedelta(days=1), time.min), + current_timezone, + ) queryset = ( TimeEntry.objects.filter( @@ -660,7 +825,11 @@ def _scope_payload(filters: ReportFilters) -> dict: "workspace": { "id": str(filters.workspace.id), "name": filters.workspace.name, - "thumbnail_path": filters.workspace.thumbnail.path if getattr(filters.workspace, "thumbnail", None) else None, + "thumbnail_path": ( + filters.workspace.thumbnail.path + if getattr(filters.workspace, "thumbnail", None) + else None + ), }, "period": filters.period, "from_date": filters.from_date.isoformat(), @@ -684,16 +853,29 @@ def _table_report_payload( ) -> dict: summary = _summary_from_entries(entries) include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) + client_rows = _build_breakdown(entries, "clients", language=filters.language) + project_rows = _build_breakdown(entries, "projects", language=filters.language) + tag_rows = _build_breakdown(entries, "tags", language=filters.language) payload = { "scope": _scope_payload(filters), "summary": summary, "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"), + "clients": client_rows, + "projects": project_rows, + "tags": tag_rows, } if filters.is_workspace_scope and not filters.user_id: - payload.update(_build_overall_percentage_payload(entries)) + payload.update( + _build_overall_percentage_payload( + entries, + language=filters.language, + rows_by_kind={ + "clients": client_rows, + "projects": project_rows, + "tags": tag_rows, + }, + ) + ) if user_summary is not None: payload["user_summary"] = user_summary if user_summaries is not None: @@ -761,64 +943,31 @@ def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list return rows -def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]: +def _build_breakdown(entries: list[TimeEntry], kind: str, *, language: str) -> list[dict]: data: dict[str, dict] = {} for entry in entries: - if kind == "clients": - if not entry.project or not entry.project.client: - continue - item_id = str(entry.project.client_id) - item_name = entry.project.client.name - elif kind == "projects": - if not entry.project: - continue - item_id = str(entry.project_id) - item_name = entry.project.name - else: - if not entry.tags.exists(): - continue - for tag in entry.tags.all(): - bucket = data.setdefault( - str(tag.id), - { - "id": str(tag.id), - "name": tag.name, - "billable_seconds": 0, - "non_billable_seconds": 0, - "total_seconds": 0, - "income": _money_map(), - }, - ) - duration_seconds = get_entry_duration_seconds(entry) - bucket["total_seconds"] += duration_seconds - if entry.is_billable: - bucket["billable_seconds"] += duration_seconds - else: - bucket["non_billable_seconds"] += duration_seconds - _add_income(bucket["income"], entry) - continue - - bucket = data.setdefault( - item_id, - { - "id": item_id, - "name": item_name, - "billable_seconds": 0, - "non_billable_seconds": 0, - "total_seconds": 0, - "income": _money_map(), - }, - ) duration_seconds = get_entry_duration_seconds(entry) - bucket["total_seconds"] += duration_seconds - if entry.is_billable: - bucket["billable_seconds"] += duration_seconds - else: - bucket["non_billable_seconds"] += duration_seconds - _add_income(bucket["income"], entry) + for item_id, item_name in _breakdown_targets(entry, kind, language): + bucket = data.setdefault( + item_id, + { + "id": item_id, + "name": item_name, + "billable_seconds": 0, + "non_billable_seconds": 0, + "total_seconds": 0, + "income": _money_map(), + }, + ) + bucket["total_seconds"] += duration_seconds + if entry.is_billable: + bucket["billable_seconds"] += duration_seconds + else: + bucket["non_billable_seconds"] += duration_seconds + _add_income(bucket["income"], entry) rows = [] - for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()): + for _item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()): rows.append( { "id": bucket["id"], @@ -839,8 +988,16 @@ def build_table_report(actor, raw_filters) -> dict: filters = load_report_filters(actor, raw_filters) entries = list(_base_queryset(filters)) if filters.is_workspace_scope and not filters.user_id: - return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries)) - user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None + return _table_report_payload( + filters, + entries, + user_summaries=_build_user_summaries(entries, language=filters.language), + ) + user_summary = ( + _build_user_summary(entries[0].user, entries, language=filters.language) + if entries and filters.user_id + else None + ) return _table_report_payload(filters, entries, user_summary=user_summary) @@ -866,7 +1023,11 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: _table_report_payload( user_filters, user_entries, - user_summary=_build_user_summary(user_entries[0].user, user_entries), + user_summary=_build_user_summary( + user_entries[0].user, + user_entries, + language=filters.language, + ), ) ) return reports diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py index c712a60..c41be9a 100644 --- a/apps/reports/services/export_i18n.py +++ b/apps/reports/services/export_i18n.py @@ -1,16 +1,16 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass from datetime import date from decimal import Decimal, InvalidOperation from pathlib import Path -from typing import Iterable import jdatetime from arabic_reshaper import reshape from bidi.algorithm import get_display -PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") +PERSIAN_DIGITS = str.maketrans("0123456789", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9") ARABIC_RANGES = ( (0x0600, 0x06FF), (0x0750, 0x077F), @@ -50,6 +50,8 @@ TRANSLATIONS = { "from": "From", "to": "To", "percentage": "Percentage", + "hour_percentage": "Hour %", + "income_percentage": "Income %", "none": "None", "daily_summary": "Daily Summary", "clients": "Clients", @@ -59,45 +61,53 @@ TRANSLATIONS = { "name": "Name", "total": "Total", "no_data": "No data", + "uncategorized_client": "No client", + "uncategorized_project": "No project", + "uncategorized_tag": "No tag", }, "fa": { - "report_title": "گزارش فضای کاری", - "overall_sheet": "گزارش کلی", - "users_summary_sheet": "خلاصه کاربران", - "workspace": "فضای کاری", - "period": "بازه", - "from_date": "از تاریخ", - "to_date": "تا تاریخ", - "user": "کاربر", - "mobile": "موبایل", - "all_users": "همه کاربران", - "generated_at": "تاریخ تولید", - "summary": "خلاصه", - "total_hours": "کل ساعات", - "billable_hours": "ساعات کاری", - "non_billable_hours": "ساعات غیر کاری", - "hourly_rate": "نرخ ساعتی", - "income": "کارکرد", - "working_hours": "ساعات کاری", - "non_working_hours": "ساعات غیرکاری", - "hourly_rates": "نرخ‌های ساعتی", - "project_percentages": "درصد پروژه‌ها", - "client_percentages": "درصد مشتری‌ها", - "tag_percentages": "درصد تگ‌ها", - "summary_by_user": "خلاصه کاربران", - "rate_history": "تاریخچه نرخ ساعتی", - "from": "از", - "to": "تا", - "percentage": "درصد", - "none": "بدون مورد", - "daily_summary": "خلاصه روزانه", - "clients": "مشتریان", - "projects": "پروژه‌ها", - "tags": "تگ‌ها", - "date": "تاریخ", - "name": "نام", - "total": "جمع", - "no_data": "بدون داده", + "report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", + "overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc", + "users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", + "workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", + "period": "\u0628\u0627\u0632\u0647", + "from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e", + "to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e", + "user": "\u06a9\u0627\u0631\u0628\u0631", + "mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644", + "all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", + "generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f", + "summary": "\u062e\u0644\u0627\u0635\u0647", + "total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a", + "billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", + "non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc", + "hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", + "income": "\u06a9\u0627\u0631\u06a9\u0631\u062f", + "working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", + "non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc", + "hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc", + "project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", + "client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627", + "tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627", + "summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", + "rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", + "from": "\u0627\u0632", + "to": "\u062a\u0627", + "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", + "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", + "projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", + "tags": "\u062a\u06af\u200c\u0647\u0627", + "date": "\u062a\u0627\u0631\u06cc\u062e", + "name": "\u0646\u0627\u0645", + "total": "\u062c\u0645\u0639", + "no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647", + "uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc", + "uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647", + "uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af", }, } @@ -111,23 +121,23 @@ PERIOD_LABELS = { "period": "Custom period", }, "fa": { - "this_week": "این هفته", - "this_month": "این ماه", - "this_year": "امسال", - "half_year_first": "نیمه اول سال", - "half_year_second": "نیمه دوم سال", - "period": "بازه دلخواه", + "this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647", + "this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647", + "this_year": "\u0627\u0645\u0633\u0627\u0644", + "half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644", + "half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644", + "period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647", }, } CURRENCY_LABELS = { - "USD": {"en": "USD", "fa": "دلار آمریکا"}, - "EUR": {"en": "EUR", "fa": "یورو"}, - "GBP": {"en": "GBP", "fa": "پوند"}, - "IRR": {"en": "IRR", "fa": "ریال"}, - "IRT": {"en": "IRT", "fa": "تومان"}, - "AED": {"en": "AED", "fa": "درهم"}, - "TRY": {"en": "TRY", "fa": "لیر"}, + "USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"}, + "EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"}, + "GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"}, + "IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"}, + "IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"}, + "AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"}, + "TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"}, } @@ -225,7 +235,7 @@ def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits: def safe_sheet_title(title: str, used: Iterable[str]) -> str: - invalid = set('[]:*?/\\') + invalid = set("[]:*?/\\") sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet" base = sanitized[:31] used_set = set(used) diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index 17db623..c65081a 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -173,7 +173,13 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary: [locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)], ), _excel_pair_row( - [locale.t("non_working_hours"), locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True)], + [ + locale.t("non_working_hours"), + locale.format_duration( + user_summary["non_billable_duration"], + ascii_digits=True, + ), + ], ), _excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]), ): @@ -184,14 +190,49 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, row_id = str(row_data.get("id")) if row_data.get("id") is not None else None row_name = row_data.get("name") for row in rows: - value = f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}%" + value = _percentage_value(locale, row["percentage"], ascii_digits=ascii_digits) if row_id is not None and str(row["id"]) == row_id: - return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value + return value if row_name and row["name"] == row_name: - return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value + return value return "-" +def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str: + value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%" + return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value + + +def _summary_breakdown_rows( + locale: ExportLocale, + hour_rows: list[dict], + income_rows: list[dict], +) -> list[list[str]]: + if not hour_rows: + return [] + + return [ + [ + row["name"], + _percentage_value(locale, row["percentage"], ascii_digits=True), + _percentage_display(locale, income_rows, row, ascii_digits=True), + ] + for row in hour_rows + ] + + +def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, ascii_digits: bool = False) -> str: + if not rate_periods: + return locale.t("none") + + first_row = rate_periods[0] + last_row = rate_periods[-1] + 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)}" + ) + + 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) @@ -252,8 +293,16 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]])) worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]])) worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])])) - worksheet.append(_excel_pair_row([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) - worksheet.append(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) + worksheet.append( + _excel_pair_row( + [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)], + ) + ) + worksheet.append( + _excel_pair_row( + [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)], + ) + ) worksheet.append( _excel_pair_row( [ @@ -266,16 +315,48 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> _excel_pair_row( [ locale.t("mobile"), - locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-", + ( + locale.format_number( + scope["user"]["mobile"], + ascii_digits=True, + ) + if scope.get("user") and scope["user"].get("mobile") + else "-" + ), + ], + ) + ) + worksheet.append( + _excel_pair_row( + [ + locale.t("generated_at"), + locale.format_date(datetime.now().date(), ascii_digits=True), ], ) ) - worksheet.append(_excel_pair_row([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])) worksheet.append([]) _append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2) - worksheet.append(_excel_pair_row([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)])) - worksheet.append(_excel_pair_row([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)])) - worksheet.append(_excel_pair_row([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)])) + worksheet.append( + _excel_pair_row( + [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)], + ) + ) + worksheet.append( + _excel_pair_row( + [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)], + ) + ) + worksheet.append( + _excel_pair_row( + [ + locale.t("non_billable_hours"), + locale.format_duration( + summary["non_billable_duration"], + ascii_digits=True, + ), + ], + ) + ) worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])])) for row_index in range(1, worksheet.max_row + 1): @@ -284,7 +365,12 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> 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) + _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=None, rtl=locale.is_rtl) if second_cell.value: @@ -353,25 +439,26 @@ def _append_breakdown_table( locale: ExportLocale, title_key: str, rows: list[dict], - percentages: list[dict] | None = None, + hour_percentages: list[dict] | None = None, + income_percentages: list[dict] | None = None, ) -> None: worksheet.append([]) _append_merged_heading( worksheet, locale=locale, title=locale.t(title_key), - span=6 if percentages is not None else 5, + span=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"), locale.t("income"), + *( [locale.t("income_percentage")] if hour_percentages is not None else [] ), ] - if percentages is not None: - headers.append(locale.t("percentage")) worksheet.append(_excel_table_row(headers)) for cell in worksheet[header_row]: _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) @@ -385,15 +472,21 @@ def _append_breakdown_table( values = [ row["name"], locale.format_duration(row["billable_duration"], ascii_digits=True), + *( + [_percentage_display(locale, hour_percentages or [], row, ascii_digits=True)] + 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), _money_label_excel(locale, row["income_totals"]), + *( + [_percentage_display(locale, income_percentages or [], row, ascii_digits=True)] + if hour_percentages is not None + else [] + ), ] - if percentages is not None: - values.append(_percentage_display(locale, percentages, row, ascii_digits=True)) - worksheet.append( - _excel_table_row(values) - ) + worksheet.append(_excel_table_row(values)) for cell in worksheet[worksheet.max_row]: _apply_cell_style(cell, rtl=locale.is_rtl) @@ -415,21 +508,24 @@ def _append_user_details_block_excel( locale=locale, title_key="clients", rows=report_data["clients"], - percentages=user_summary["client_percentages"], + hour_percentages=user_summary["client_percentages"], + income_percentages=user_summary["client_income_percentages"], ) _append_breakdown_table( worksheet, locale=locale, title_key="projects", rows=report_data["projects"], - percentages=user_summary["project_percentages"], + hour_percentages=user_summary["project_percentages"], + income_percentages=user_summary["project_income_percentages"], ) _append_breakdown_table( worksheet, locale=locale, title_key="tags", rows=report_data["tags"], - percentages=user_summary["tag_percentages"], + hour_percentages=user_summary["tag_percentages"], + income_percentages=user_summary["tag_income_percentages"], ) @@ -469,22 +565,28 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, rate_rows = [ [ _rate_period_label(locale, row, ascii_digits=True), - f"{locale.format_date(row['from_date'], ascii_digits=True)} - {locale.format_date(row['to_date'], 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 = [ - [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] - for row in (summary.get("client_percentages") or []) - ] - project_rows = [ - [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] - for row in (summary.get("project_percentages") or []) - ] - tag_rows = [ - [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] - for row in (summary.get("tag_percentages") or []) - ] + client_rows = _summary_breakdown_rows( + locale, + summary.get("client_percentages") or [], + summary.get("client_income_percentages") or [], + ) + project_rows = _summary_breakdown_rows( + locale, + summary.get("project_percentages") or [], + summary.get("project_income_percentages") or [], + ) + tag_rows = _summary_breakdown_rows( + locale, + summary.get("tag_percentages") or [], + summary.get("tag_income_percentages") or [], + ) span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1) rows: list[list[str | None]] = [] for index in range(span): @@ -494,11 +596,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, 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, - _money_label_excel(locale, summary["income_totals"]) if index == 0 else None, *(rate_rows[index] if index < len(rate_rows) else [None, None]), - *(client_rows[index] if index < len(client_rows) else [None, None]), - *(project_rows[index] if index < len(project_rows) else [None, None]), - *(tag_rows[index] if index < len(tag_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]), + *(project_rows[index] if index < len(project_rows) else [None, None, None]), + *(tag_rows[index] if index < len(tag_rows) else [None, None, None]), ], ) return span, rows @@ -560,7 +662,7 @@ def _render_all_users_overall_excel_sheet( worksheet, row=15, start_col=1, - end_col=13, + end_col=16, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl, ) @@ -569,15 +671,18 @@ def _render_all_users_overall_excel_sheet( locale.t("mobile"), locale.t("working_hours"), locale.t("non_working_hours"), - locale.t("income"), locale.t("hourly_rate"), locale.t("period"), + locale.t("income"), locale.t("clients"), - locale.t("percentage"), + locale.t("hour_percentage"), + locale.t("income_percentage"), locale.t("projects"), - locale.t("percentage"), + locale.t("hour_percentage"), + locale.t("income_percentage"), locale.t("tags"), - locale.t("percentage"), + locale.t("hour_percentage"), + locale.t("income_percentage"), ] _write_table_row( worksheet, @@ -599,33 +704,58 @@ def _render_all_users_overall_excel_sheet( values=values, rtl=locale.is_rtl, ) - for column in range(1, 6): + for column in (1, 2, 3, 4, 7): _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=5, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True) - _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True) if len(client_rows) == 1: _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) - if len(project_rows) == 1: _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=11, value_present=True) - if len(tag_rows) == 1: _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=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 - for title_key, rows, percentages in ( - ("clients", report_data["clients"], report_data.get("client_percentages")), - ("projects", report_data["projects"], report_data.get("project_percentages")), - ("tags", report_data["tags"], report_data.get("tag_percentages")), + for title_key, rows, hour_percentages, income_percentages in ( + ( + "clients", + report_data["clients"], + report_data.get("client_percentages"), + report_data.get("client_income_percentages"), + ), + ( + "projects", + report_data["projects"], + report_data.get("project_percentages"), + report_data.get("project_income_percentages"), + ), + ( + "tags", + report_data["tags"], + report_data.get("tag_percentages"), + report_data.get("tag_income_percentages"), + ), ): - _merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, value=locale.t(title_key), rtl=locale.is_rtl) + _merge_and_style( + worksheet, + row=current_row, + start_col=1, + end_col=7, + value=locale.t(title_key), + rtl=locale.is_rtl, + ) current_row += 1 _write_table_row( worksheet, @@ -634,10 +764,11 @@ def _render_all_users_overall_excel_sheet( values=[ 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("percentage"), + locale.t("income_percentage"), ], rtl=locale.is_rtl, bold=True, @@ -653,10 +784,11 @@ def _render_all_users_overall_excel_sheet( values=[ 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, percentages or [], row, ascii_digits=True), + _percentage_display(locale, income_percentages or [], row, ascii_digits=True), ], rtl=locale.is_rtl, ) @@ -666,7 +798,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], + values=[locale.t("no_data"), None, None, None, None, None, None], rtl=locale.is_rtl, ) current_row += 1 @@ -677,15 +809,18 @@ def _render_all_users_overall_excel_sheet( "B": 19.86, "C": 18.0, "D": 17.0, - "E": 24.0, - "F": 17.57, - "G": 32.0, - "H": 30.0, + "E": 18.0, + "F": 26.0, + "G": 24.0, + "H": 28.0, "I": 14.0, - "J": 32.86, - "K": 12.0, - "L": 22.0, - "M": 12.0, + "J": 16.0, + "K": 28.0, + "L": 14.0, + "M": 16.0, + "N": 24.0, + "O": 14.0, + "P": 16.0, } for column, width in overall_widths.items(): worksheet.column_dimensions[column].width = width @@ -715,21 +850,48 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) - locale=locale, title_key="clients", rows=report_data["clients"], - percentages=user_summary["client_percentages"] if user_summary else None, + hour_percentages=( + user_summary["client_percentages"] + if user_summary + else report_data.get("client_percentages") + ), + income_percentages=( + user_summary["client_income_percentages"] + if user_summary + else report_data.get("client_income_percentages") + ), ) _append_breakdown_table( worksheet, locale=locale, title_key="projects", rows=report_data["projects"], - percentages=user_summary["project_percentages"] if user_summary else None, + hour_percentages=( + user_summary["project_percentages"] + if user_summary + else report_data.get("project_percentages") + ), + income_percentages=( + user_summary["project_income_percentages"] + if user_summary + else report_data.get("project_income_percentages") + ), ) _append_breakdown_table( worksheet, locale=locale, title_key="tags", rows=report_data["tags"], - percentages=user_summary["tag_percentages"] if user_summary else None, + hour_percentages=( + user_summary["tag_percentages"] + if user_summary + else report_data.get("tag_percentages") + ), + income_percentages=( + user_summary["tag_income_percentages"] + if user_summary + else report_data.get("tag_income_percentages") + ), ) _autosize_columns(worksheet) @@ -764,7 +926,14 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo _render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data) used_titles.add(overall_sheet.title) for user_report in per_user_reports: - user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles) + user_title = safe_sheet_title( + user_label( + user_report["scope"].get("user"), + locale, + ascii_digits=True, + ), + used_titles, + ) worksheet = workbook.create_sheet(title=user_title) _render_excel_sheet(worksheet, locale=locale, report_data=user_report) used_titles.add(user_title) @@ -924,61 +1093,77 @@ def _append_pdf_report_sections( for title_key, rows, is_daily in sections: story.append(_paragraph(locale.t(title_key), section_style, locale)) story.append(Spacer(1, 2 * mm)) - header_values = [ - 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"), - ] - percentage_rows = None + header_values = ( + [ + locale.t("date"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + locale.t("hourly_rate"), + locale.t("income"), + ] + if is_daily + else [ + 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"), + ] + ) + hour_percentage_rows = None + income_percentage_rows = None if user_summary and not is_daily: - percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"] - header_values.append(locale.t("percentage")) + prefix = title_key[:-1] if title_key != "clients" else "client" + hour_percentage_rows = user_summary[f"{prefix}_percentages"] + income_percentage_rows = user_summary[f"{prefix}_income_percentages"] header = _rtl_row(locale, header_values) body_rows = _report_table_rows(locale, rows, is_daily=is_daily) - if percentage_rows is not None: + if hour_percentage_rows is not None: body_rows = [ _rtl_row( locale, [ 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"]), _money_label(locale, row["income_totals"]), - _percentage_display(locale, percentage_rows, row), + _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"), "", "", "", "", "", ""])] table = _styled_table( [header, *body_rows], locale=locale, column_widths=( [ - doc_width * 0.21, - doc_width * 0.13, + doc_width * 0.20, + doc_width * 0.12, doc_width * 0.15, doc_width * 0.13, doc_width * 0.16, - doc_width * 0.22, + doc_width * 0.24, ] if is_daily else [ *( [ - doc_width * 0.24, - doc_width * 0.13, - doc_width * 0.15, + doc_width * 0.20, + doc_width * 0.11, + doc_width * 0.11, doc_width * 0.12, - doc_width * 0.2, - doc_width * 0.16, + doc_width * 0.12, + doc_width * 0.19, + doc_width * 0.15, ] - if percentage_rows is not None + if hour_percentage_rows is not None else [ - doc_width * 0.26, + doc_width * 0.13, doc_width * 0.15, doc_width * 0.17, doc_width * 0.14, @@ -1063,7 +1248,14 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report [locale.t("from_date"), locale.format_date(scope["from_date"])], [locale.t("to_date"), locale.format_date(scope["to_date"])], [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("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: @@ -1122,6 +1314,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report locale.t("mobile"), locale.t("working_hours"), locale.t("non_working_hours"), + locale.t("hourly_rate"), + locale.t("period"), locale.t("income"), ], ) @@ -1133,6 +1327,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report 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 []), _money_label(locale, summary["income_totals"]), ], ) @@ -1143,11 +1339,13 @@ 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.24, doc.width * 0.18, - doc.width * 0.18, - doc.width * 0.18, - doc.width * 0.22, + doc.width * 0.13, + doc.width * 0.13, + doc.width * 0.13, + doc.width * 0.13, + doc.width * 0.16, + doc.width * 0.14, ], ) ) diff --git a/apps/reports/tests/test_exporters.py b/apps/reports/tests/test_exporters.py index 4f26aa7..ebe1a93 100644 --- a/apps/reports/tests/test_exporters.py +++ b/apps/reports/tests/test_exporters.py @@ -68,6 +68,9 @@ def make_user_summary(*, name: str, mobile: str): "project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}], "client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}], "tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}], + "project_income_percentages": [{"id": "1", "name": "Website", "percentage": "100"}], + "client_income_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}], + "tag_income_percentages": [{"id": "1", "name": "Design", "percentage": "100"}], } @@ -120,23 +123,26 @@ 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:M15", {str(item) for item in summary_sheet.merged_cells.ranges}) + self.assertIn("A15:P15", {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][:13], + tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16], ( "Name", "Mobile", "Working hours", "Non-working hours", - "Income", "Hourly rate", "Period", + "Income", "Clients", - "Percentage", + "Hour %", + "Income %", "Projects", - "Percentage", + "Hour %", + "Income %", "Tags", - "Percentage", + "Hour %", + "Income %", ), ) self.assertTrue(any(row and "Owner User" in row for row in summary_values)) @@ -161,6 +167,20 @@ class ReportExporterTests(TestCase): daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row) self.assertEqual(daily_row[4], "15 USD") + breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %") + self.assertEqual( + breakdown_header, + ( + "Name", + "Billable hours", + "Hour %", + "Non-billable hours", + "Total hours", + "Income", + "Income %", + ), + ) + def test_pdf_export_supports_persian_locale(self): locale = build_export_locale("fa") report_data = make_report_data( @@ -168,7 +188,16 @@ class ReportExporterTests(TestCase): ) report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")] per_user_reports = [ - {**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")} + { + **make_report_data( + user_name="Owner User", + mobile="09129990001", + ), + "user_summary": make_user_summary( + name="Owner User", + mobile="09129990001", + ), + } ] content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) diff --git a/apps/reports/tests/test_views.py b/apps/reports/tests/test_views.py index fa74740..a76ad2a 100644 --- a/apps/reports/tests/test_views.py +++ b/apps/reports/tests/test_views.py @@ -147,9 +147,130 @@ class ReportViewTests(APITestCase): self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100") - self.assertEqual(member_summary["project_percentages"], []) - self.assertEqual(member_summary["client_percentages"], []) - self.assertEqual(member_summary["tag_percentages"], []) + self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0") + self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0") + self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0") + + def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self): + self.client.force_authenticate(user=self.owner) + + TimeEntry.objects.create( + workspace=self.workspace, + user=self.owner, + project=None, + description="Uncategorized billable", + start_time="2026-04-12T10:00:00+03:30", + end_time="2026-04-12T11:00:00+03:30", + duration=timedelta(hours=1), + is_billable=True, + hourly_rate=Decimal("10.00"), + currency="USD", + ) + + with patch( + "apps.reports.services.aggregation.timezone.localdate", + return_value=date(2026, 4, 20), + ): + response = self.client.get( + "/api/reports/table/", + { + "workspace": str(self.workspace.id), + "period": "this_month", + "user": str(self.owner.id), + "language": "en", + }, + ) + + self.assertEqual(response.status_code, 200) + summary = response.data["user_summary"] + self.assertEqual( + sum(int(row["percentage"]) for row in summary["project_percentages"]), + 100, + ) + self.assertEqual( + sum(int(row["percentage"]) for row in summary["client_percentages"]), + 100, + ) + self.assertEqual( + sum(int(row["percentage"]) for row in summary["tag_percentages"]), + 100, + ) + self.assertEqual( + {row["name"] for row in summary["project_percentages"]}, + {"Website", "No project"}, + ) + self.assertEqual( + {row["name"] for row in summary["client_percentages"]}, + {"Acme", "No client"}, + ) + self.assertEqual( + {row["name"] for row in summary["tag_percentages"]}, + {"Design", "No tag"}, + ) + self.assertEqual( + {row["name"] for row in response.data["projects"]}, + {"Website", "No project"}, + ) + self.assertEqual( + {row["name"] for row in response.data["clients"]}, + {"Acme", "No client"}, + ) + self.assertEqual( + {row["name"] for row in response.data["tags"]}, + {"Design", "No tag"}, + ) + self.assertEqual( + sum(int(row["percentage"]) for row in summary["project_income_percentages"]), + 100, + ) + self.assertEqual( + sum(int(row["percentage"]) for row in summary["client_income_percentages"]), + 100, + ) + self.assertEqual( + sum(int(row["percentage"]) for row in summary["tag_income_percentages"]), + 100, + ) + + def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self): + self.client.force_authenticate(user=self.owner) + second_project = Project.objects.create( + workspace=self.workspace, + name="Mobile App", + client=self.client_obj, + ) + + TimeEntry.objects.create( + workspace=self.workspace, + user=self.owner, + project=second_project, + description="EUR work", + start_time="2026-04-13T10:00:00+03:30", + end_time="2026-04-13T11:00:00+03:30", + duration=timedelta(hours=1), + is_billable=True, + hourly_rate=Decimal("20.00"), + currency="EUR", + ) + + with patch( + "apps.reports.services.aggregation.timezone.localdate", + return_value=date(2026, 4, 20), + ): + response = self.client.get( + "/api/reports/table/", + { + "workspace": str(self.workspace.id), + "period": "this_month", + "user": str(self.owner.id), + "language": "en", + }, + ) + + self.assertEqual(response.status_code, 200) + summary = response.data["user_summary"] + self.assertEqual(summary["project_income_percentages"], []) + self.assertEqual(summary["client_income_percentages"], []) def test_daily_rate_uses_latest_billable_entry_snapshot(self): self.client.force_authenticate(user=self.owner)