feat(reports): improve summary rates and export formatting
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-26 12:15:44 +03:30
parent af9facce7e
commit 20874b9968
4 changed files with 144 additions and 128 deletions

View File

@@ -18,8 +18,7 @@ from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace
from apps.workspaces.models import WorkspaceUserRate
from apps.workspaces.models import HourlyRateHistory, Workspace
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
User = get_user_model()
@@ -121,113 +120,73 @@ def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
]
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id))
periods: list[dict] = []
current: dict | None = None
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
current_timezone = timezone.get_current_timezone()
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
rows = list(
HourlyRateHistory.objects.filter(
workspace=workspace,
user=user,
is_deleted=False,
effective_from__lt=period_end,
)
.select_related("project")
.order_by("scope", "project_id", "effective_from", "created_at")
)
for entry in sorted_entries:
if not entry.hourly_rate or not entry.start_time:
continue
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
for row in rows:
grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
currency = entry.currency or "USD"
start_date = _localize_datetime(entry.start_time).date()
end_source = entry.end_time or entry.start_time
end_date = _localize_datetime(end_source).date()
serialized: list[dict] = []
for (_scope, _project_id), history_rows in grouped.items():
selected_indexes = {
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
}
previous_indexes = [
index for index, row in enumerate(history_rows) if row.effective_from < period_start
]
if previous_indexes:
selected_indexes.add(previous_indexes[-1])
if (
current
and current["amount"] == amount
and current["currency"] == currency
):
if end_date > current["to_date"]:
current["to_date"] = end_date
continue
if current:
periods.append(
for index in sorted(selected_indexes):
row = history_rows[index]
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
if next_row and next_row.effective_from < period_start:
continue
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
serialized.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
"currency": row.currency or "USD",
"from_date": from_day.isoformat(),
"to_date": to_day.isoformat() if to_day else None,
"scope": row.scope,
"project_name": row.project.name if row.project else None,
"is_current": next_row is None,
}
)
current = {
"amount": amount,
"currency": currency,
"from_date": start_date,
"to_date": end_date,
}
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
return periods
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
workspace_rate = (
WorkspaceUserRate.objects.filter(
workspace=workspace,
user=user,
is_active=True,
is_deleted=False,
)
.order_by("-effective_from", "-updated_at")
.first()
)
if not workspace_rate or not workspace_rate.effective_from:
return []
return [
{
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
"currency": workspace_rate.currency or "USD",
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
"to_date": None,
"is_current": True,
}
]
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
merged = [dict(row) for row in history_rows]
latest_indexes = {
(row["amount"], row["currency"]): index
for index, row in enumerate(merged)
}
for row in current_rows:
key = (row["amount"], row["currency"])
index = latest_indexes.get(key)
if index is not None:
merged[index]["to_date"] = None
continue
merged.append(dict(row))
latest_indexes[key] = len(merged) - 1
return sorted(
merged,
serialized,
key=lambda item: (
item["from_date"],
item["currency"],
item["scope"],
item.get("project_name") or "",
Decimal(item["amount"]),
item.get("to_date") or "9999-12-31",
),
)
def _uncategorized_label(kind: str, language: str) -> str:
if language == "fa":
return {
"clients": "بدون مشتری",
"projects": "بدون پروژه",
"tags": "بدون تگ",
}[kind]
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
return UNCATEGORIZED_LABELS[resolved_language][kind]
@@ -422,11 +381,22 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
def _build_user_summary(
user,
entries: list[TimeEntry],
*,
workspace: Workspace,
from_date: date,
to_date: date,
language: str,
) -> dict:
summary = _summary_from_entries(entries)
historical_rate_rows = _serialize_rate_periods(entries)
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace)
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows)
rate_rows = _serialize_rate_history_rows(
user=user,
workspace=workspace,
from_date=from_date,
to_date=to_date,
)
project_rows = _build_breakdown(entries, "projects", language=language)
client_rows = _build_breakdown(entries, "clients", language=language)
tag_rows = _build_breakdown(entries, "tags", language=language)
@@ -458,13 +428,20 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
}
def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]:
def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> 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, language=language)
_build_user_summary(
grouped_entries[0].user,
grouped_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
for grouped_entries in grouped.values()
if grouped_entries
]
@@ -799,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
bucket_date = local_dt.date()
return bucket_date.isoformat(), bucket_date
if filters.language == "fa":
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
bucket_date = date(local_dt.year, local_dt.month, 1)
return bucket_date.strftime("%Y-%m"), bucket_date
@@ -1045,11 +1019,18 @@ def build_table_report(actor, raw_filters) -> dict:
payload = _table_report_payload(
filters,
entries,
user_summaries=_build_user_summaries(entries, language=filters.language),
user_summaries=_build_user_summaries(entries, filters=filters),
)
return payload
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
_build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries and filters.user_id
else None
)
@@ -1063,7 +1044,14 @@ def build_user_summary_report(actor, raw_filters) -> dict:
entries = list(_base_queryset(filters))
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
_build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries
else None
)
@@ -1095,6 +1083,9 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
user_summary=_build_user_summary(
user_entries[0].user,
user_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
),
)