Add income vs expense series to dashboard
This commit is contained in:
parent
3c313aa4e4
commit
27703bd47c
@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from django.urls import reverse
|
||||
from expenses.models import Account, Expense, Category, Income
|
||||
from expenses.models import Account, Expense, Category, Income, Transfer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@ -227,6 +227,209 @@ def test_dashboard_comparison_breaks_ties_by_category_name(
|
||||
assert order == ["Alfa", "Media", "Zeta"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Serie de ingresos contra gastos
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_transfer(user, from_account, to_account, amount="400", on=None, fee=None):
|
||||
transfer = Transfer(
|
||||
owner=user,
|
||||
date=on or date(2024, 3, 10),
|
||||
amount=Decimal(amount),
|
||||
from_account=from_account,
|
||||
to_account=to_account,
|
||||
fee_amount=Decimal(fee) if fee is not None else None,
|
||||
)
|
||||
transfer.sync()
|
||||
return transfer
|
||||
|
||||
|
||||
def test_dashboard_sends_both_series_on_the_same_axis(
|
||||
auth_client, user, account, category
|
||||
):
|
||||
Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal("30"), date=date(2024, 5, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
expenses = response.context['chart_data']
|
||||
incomes = response.context['chart_income_data']
|
||||
# Mismo eje para las dos: si cada una montara el suyo, se desalinearían.
|
||||
assert len(incomes) == len(response.context['chart_labels']) == len(expenses)
|
||||
assert incomes[2] == 1500.0 # marzo
|
||||
assert expenses[4] == 30.0 # mayo
|
||||
assert incomes[4] == 0 # mayo no tuvo ingresos: cero, no hueco
|
||||
|
||||
|
||||
def test_dashboard_income_series_is_zero_filled_without_movements(auth_client):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
assert response.context['chart_income_data'] == [0] * 12
|
||||
|
||||
|
||||
def test_dashboard_income_series_is_zero_filled_in_a_month_without_movements(
|
||||
auth_client
|
||||
):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||
|
||||
assert response.context['chart_income_data'] == [0] * 31
|
||||
|
||||
|
||||
def test_dashboard_both_series_ignore_a_transfer(user, auth_client, account):
|
||||
other = Account.objects.create(
|
||||
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
_make_transfer(user, account, other, amount="400")
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
# Mandarse dinero a una cuenta propia no es ingreso ni gasto: si se
|
||||
# excluyera solo de los gastos, el gráfico daría un pico de ingresos cada
|
||||
# vez que el usuario se traspasa la nómina.
|
||||
assert sum(response.context['chart_income_data']) == 0
|
||||
assert sum(response.context['chart_data']) == 0
|
||||
|
||||
|
||||
def test_dashboard_counts_the_transfer_fee_as_an_expense(user, auth_client, account):
|
||||
other = Account.objects.create(
|
||||
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
_make_transfer(user, account, other, amount="400", fee="1.50")
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
# La comisión va con is_transfer=False: sale del patrimonio de verdad.
|
||||
assert sum(response.context['chart_data']) == 1.5
|
||||
assert sum(response.context['chart_income_data']) == 0
|
||||
|
||||
|
||||
def test_dashboard_income_series_follows_the_day_axis_in_a_short_range(
|
||||
auth_client, user, account
|
||||
):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 5),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': '2024-03-01', 'date_to': '2024-03-30'}, # 30 días
|
||||
)
|
||||
|
||||
assert response.context['chart_type'] == 'day'
|
||||
incomes = response.context['chart_income_data']
|
||||
assert len(incomes) == 30
|
||||
assert incomes[4] == 1500.0
|
||||
assert incomes[0] == 0
|
||||
|
||||
|
||||
def test_dashboard_income_series_follows_the_month_axis_in_a_long_range(
|
||||
auth_client, user, account
|
||||
):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 2, 5),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': '2024-01-01', 'date_to': '2024-04-29'}, # 120 días
|
||||
)
|
||||
|
||||
assert response.context['chart_type'] == 'month'
|
||||
incomes = response.context['chart_income_data']
|
||||
assert len(incomes) == 4
|
||||
assert incomes[1] == 1500.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_matches_the_62_day_threshold(
|
||||
auth_client, user, account
|
||||
):
|
||||
start = date(2024, 3, 1)
|
||||
at_limit = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=61)).isoformat()},
|
||||
)
|
||||
over_limit = auth_client.get(
|
||||
reverse('dashboard'),
|
||||
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=62)).isoformat()},
|
||||
)
|
||||
|
||||
# La serie de ingresos usa el mismo umbral que la de gastos, no el suyo.
|
||||
assert at_limit.context['chart_type'] == 'day'
|
||||
assert len(at_limit.context['chart_income_data']) == 62
|
||||
assert over_limit.context['chart_type'] == 'month'
|
||||
assert len(over_limit.context['chart_income_data']) == 3 # mar, abr, may
|
||||
|
||||
|
||||
def test_dashboard_open_range_axis_covers_older_incomes(auth_client, user, account, category):
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Nómina",
|
||||
amount=Decimal("1500"), date=date(2024, 3, 1),
|
||||
)
|
||||
Expense.objects.create(
|
||||
owner=user, account=account, category=category,
|
||||
amount=Decimal("30"), date=date(2024, 3, 20),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'date_to': '2024-03-25'})
|
||||
|
||||
# Sin date_from el eje se acota con el movimiento más antiguo de las dos
|
||||
# series: midiendo solo el gasto más viejo, ese ingreso se caería del eje.
|
||||
assert response.context['chart_labels'][0] == "01/03"
|
||||
assert response.context['chart_income_data'][0] == 1500.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_does_not_leak_between_users(
|
||||
auth_client, user, account, django_user_model
|
||||
):
|
||||
intruder = django_user_model.objects.create_user(username="otro", password="x")
|
||||
their_account = Account.objects.create(
|
||||
owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=intruder, account=their_account, name="Ajena",
|
||||
amount=Decimal("999"), date=date(2024, 3, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Mía",
|
||||
amount=Decimal("100"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024})
|
||||
|
||||
assert sum(response.context['chart_income_data']) == 100.0
|
||||
|
||||
|
||||
def test_dashboard_income_series_respects_the_account_filter(
|
||||
auth_client, user, account
|
||||
):
|
||||
other = Account.objects.create(
|
||||
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=account, name="Aquí",
|
||||
amount=Decimal("100"), date=date(2024, 3, 10),
|
||||
)
|
||||
Income.objects.create(
|
||||
owner=user, account=other, name="Allí",
|
||||
amount=Decimal("500"), date=date(2024, 3, 10),
|
||||
)
|
||||
|
||||
response = auth_client.get(
|
||||
reverse('dashboard'), {'year': 2024, 'account': account.id}
|
||||
)
|
||||
|
||||
assert sum(response.context['chart_income_data']) == 100.0
|
||||
|
||||
|
||||
def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client):
|
||||
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
|
||||
|
||||
|
||||
@ -143,6 +143,16 @@ def _category_tree(categories):
|
||||
return rows
|
||||
|
||||
|
||||
def _without_transfers(movements):
|
||||
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
|
||||
|
||||
Único sitio donde se decide qué cuenta como movimiento real en los análisis.
|
||||
Si cada pantalla lo filtrara por su cuenta acabaría habiendo dos criterios,
|
||||
y bastaría tocar uno para que los números dejaran de cuadrar entre sí.
|
||||
"""
|
||||
return movements.exclude(is_transfer=True)
|
||||
|
||||
|
||||
def _analysis_expenses(user):
|
||||
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
|
||||
|
||||
@ -154,7 +164,64 @@ def _analysis_expenses(user):
|
||||
monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
|
||||
y filtrarlos descuadraría todos los saldos.
|
||||
"""
|
||||
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
|
||||
return _without_transfers(Expense.objects.filter(owner=user))
|
||||
|
||||
|
||||
def _analysis_incomes(user):
|
||||
"""Ingresos para análisis: el espejo de _analysis_expenses.
|
||||
|
||||
Hace falta porque si los traspasos se excluyeran solo del lado de los
|
||||
gastos, el gráfico de ingresos contra gastos daría un pico de ingresos cada
|
||||
vez que el usuario se traspasa la nómina a otra cuenta propia.
|
||||
|
||||
La misma advertencia que en los gastos: esto no vale para saldos.
|
||||
"""
|
||||
return _without_transfers(Income.objects.filter(owner=user))
|
||||
|
||||
|
||||
def _bucket_totals(movements, bucket):
|
||||
"""Suma importes agrupados según el eje del gráfico del dashboard.
|
||||
|
||||
`bucket` lo decide quien construye el eje, y las dos series del gráfico de
|
||||
ingresos contra gastos lo comparten: si cada una se agrupara por su cuenta,
|
||||
los ejes divergirían en cuanto una tuviera un hueco que la otra no.
|
||||
|
||||
Son dos consultas separadas (una por serie) que se cruzan después en Python.
|
||||
No se puede juntar en un annotate() de dos Sum sobre el mismo queryset: el
|
||||
JOIN multiplica filas e infla los totales.
|
||||
"""
|
||||
if bucket == "date":
|
||||
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||
# DateField y TruncDate le aplicaría una conversión de zona horaria
|
||||
# que en SQLite falla si no está la base de datos tz.
|
||||
rows = movements.values("date").annotate(total=Sum("amount"))
|
||||
return {row["date"]: float(row["total"]) for row in rows}
|
||||
|
||||
if bucket == "year_month":
|
||||
rows = (
|
||||
movements.annotate(bucket=TruncMonth("date"))
|
||||
.values("bucket")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {
|
||||
(row["bucket"].year, row["bucket"].month): float(row["total"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
if bucket == "day_of_month":
|
||||
rows = (
|
||||
movements.annotate(day=ExtractDay("date"))
|
||||
.values("day")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["day"]: float(row["total"]) for row in rows}
|
||||
|
||||
rows = (
|
||||
movements.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
return {row["month"]: float(row["total"]) for row in rows}
|
||||
|
||||
|
||||
def _reject_transfer_leg(request, movement):
|
||||
@ -720,16 +787,27 @@ def dashboard(request):
|
||||
if account_id:
|
||||
expenses = expenses.filter(account_id=account_id)
|
||||
|
||||
# Los ingresos van en paralelo y con los mismos filtros: son la otra serie
|
||||
# del gráfico de ingresos contra gastos.
|
||||
incomes = _analysis_incomes(request.user)
|
||||
if account_id:
|
||||
incomes = incomes.filter(account_id=account_id)
|
||||
|
||||
if range_active:
|
||||
expenses_filtered = expenses
|
||||
incomes_filtered = incomes
|
||||
if date_from:
|
||||
expenses_filtered = expenses_filtered.filter(date__gte=date_from)
|
||||
incomes_filtered = incomes_filtered.filter(date__gte=date_from)
|
||||
if date_to:
|
||||
expenses_filtered = expenses_filtered.filter(date__lte=date_to)
|
||||
incomes_filtered = incomes_filtered.filter(date__lte=date_to)
|
||||
else:
|
||||
expenses_filtered = expenses.filter(date__year=selected_year)
|
||||
incomes_filtered = incomes.filter(date__year=selected_year)
|
||||
if selected_month:
|
||||
expenses_filtered = expenses_filtered.filter(date__month=selected_month)
|
||||
incomes_filtered = incomes_filtered.filter(date__month=selected_month)
|
||||
|
||||
|
||||
# Basic KPIs
|
||||
@ -760,20 +838,35 @@ def dashboard(request):
|
||||
})
|
||||
|
||||
#Graphic
|
||||
# El eje se construye una sola vez (claves + etiquetas + agrupación) y las
|
||||
# dos series del gráfico de ingresos contra gastos se calculan contra él.
|
||||
# Si cada serie montara su propio eje, dos huecos distintos las
|
||||
# desalinearían y el gráfico mentiría.
|
||||
daily_average = 0
|
||||
projected_end_of_month = 0
|
||||
if range_active:
|
||||
# Con rango libre el eje no lo decide el mes sino la longitud del rango.
|
||||
# Si falta un extremo se acota para poder medirlo: por abajo con el gasto
|
||||
# más antiguo del conjunto, por arriba con hoy.
|
||||
span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"]
|
||||
# Si falta un extremo se acota para poder medirlo: por abajo con el
|
||||
# movimiento más antiguo del conjunto, por arriba con hoy.
|
||||
span_from = date_from
|
||||
if span_from is None:
|
||||
# Se mira el más antiguo de LAS DOS series: acotando solo por el
|
||||
# gasto más viejo, los ingresos anteriores se caerían del eje sin
|
||||
# que nada lo avisara.
|
||||
firsts = [
|
||||
expenses_filtered.aggregate(first=Min("date"))["first"],
|
||||
incomes_filtered.aggregate(first=Min("date"))["first"],
|
||||
]
|
||||
firsts = [first for first in firsts if first is not None]
|
||||
span_from = min(firsts) if firsts else None
|
||||
span_to = date_to or today
|
||||
|
||||
if span_from is None:
|
||||
# Sin gastos y sin fecha inicial no hay longitud que medir.
|
||||
# Sin movimientos y sin fecha inicial no hay longitud que medir.
|
||||
axis_keys = []
|
||||
chart_labels = []
|
||||
chart_totals = []
|
||||
chart_type = "month"
|
||||
bucket = "year_month"
|
||||
else:
|
||||
span_days = (span_to - span_from).days + 1
|
||||
daily_average = total_amount / span_days if span_days > 0 else 0
|
||||
@ -781,71 +874,49 @@ def dashboard(request):
|
||||
# un rango arbitrario no significa nada.
|
||||
|
||||
if span_days <= RANGE_DAY_CHART_MAX_DAYS:
|
||||
# Agrupar por el propio campo, no con TruncDate: "date" ya es un
|
||||
# DateField y TruncDate le aplicaría una conversión de zona
|
||||
# horaria que en SQLite falla si no está la base de datos tz.
|
||||
by_day_qs = expenses_filtered.values("date").annotate(
|
||||
total=Sum("amount")
|
||||
)
|
||||
day_totals = {row["date"]: float(row["total"]) for row in by_day_qs}
|
||||
|
||||
axis = [span_from + timedelta(days=i) for i in range(span_days)]
|
||||
chart_labels = [d.strftime("%d/%m") for d in axis]
|
||||
chart_totals = [day_totals.get(d, 0) for d in axis]
|
||||
axis_keys = [span_from + timedelta(days=i) for i in range(span_days)]
|
||||
chart_labels = [d.strftime("%d/%m") for d in axis_keys]
|
||||
chart_type = "day"
|
||||
bucket = "date"
|
||||
else:
|
||||
by_month_qs = (
|
||||
expenses_filtered.annotate(bucket=TruncMonth("date"))
|
||||
.values("bucket")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
month_totals = {
|
||||
(row["bucket"].year, row["bucket"].month): float(row["total"])
|
||||
for row in by_month_qs
|
||||
}
|
||||
|
||||
axis = []
|
||||
axis_keys = []
|
||||
cursor = (span_from.year, span_from.month)
|
||||
last = (span_to.year, span_to.month)
|
||||
while cursor <= last:
|
||||
axis.append(cursor)
|
||||
axis_keys.append(cursor)
|
||||
y, m = cursor
|
||||
cursor = (y + 1, 1) if m == 12 else (y, m + 1)
|
||||
|
||||
chart_labels = [f"{m:02d}/{y}" for y, m in axis]
|
||||
chart_totals = [month_totals.get(key, 0) for key in axis]
|
||||
chart_labels = [f"{m:02d}/{y}" for y, m in axis_keys]
|
||||
chart_type = "month"
|
||||
bucket = "year_month"
|
||||
elif selected_month:
|
||||
by_day_qs = (
|
||||
expenses_filtered.annotate(day=ExtractDay("date"))
|
||||
.values("day")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
day_totals = {row["day"]: float(row["total"]) for row in by_day_qs}
|
||||
|
||||
num_days = calendar.monthrange(selected_year, selected_month)[1]
|
||||
if selected_year == today.year and selected_month == today.month:
|
||||
days_passed = today.day
|
||||
else:
|
||||
days_passed = calendar.monthrange(selected_year, selected_month)[1]
|
||||
|
||||
|
||||
daily_average = total_amount / days_passed if days_passed > 0 else 0
|
||||
total_days_in_month = calendar.monthrange(selected_year, selected_month)[1]
|
||||
projected_end_of_month = daily_average * total_days_in_month
|
||||
|
||||
chart_labels = list(range(1, num_days + 1))
|
||||
chart_totals = [day_totals.get(d, 0) for d in chart_labels]
|
||||
|
||||
axis_keys = list(range(1, num_days + 1))
|
||||
chart_labels = axis_keys
|
||||
chart_type = "day"
|
||||
bucket = "day_of_month"
|
||||
else:
|
||||
by_month_qs = (
|
||||
expenses_filtered.annotate(month=ExtractMonth("date"))
|
||||
.values("month")
|
||||
.annotate(total=Sum("amount"))
|
||||
)
|
||||
month_totals = {row["month"]: float(row["total"]) for row in by_month_qs}
|
||||
axis_keys = list(range(1, 13))
|
||||
chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
|
||||
chart_totals = [month_totals.get(m, 0) for m in range(1, 13)]
|
||||
chart_type = "month"
|
||||
bucket = "month_of_year"
|
||||
|
||||
# Los ceros son obligatorios: un día o un mes sin movimientos tiene que
|
||||
# salir con 0, no desaparecer del eje.
|
||||
expense_by_bucket = _bucket_totals(expenses_filtered, bucket)
|
||||
income_by_bucket = _bucket_totals(incomes_filtered, bucket)
|
||||
chart_totals = [expense_by_bucket.get(key, 0) for key in axis_keys]
|
||||
chart_income_totals = [income_by_bucket.get(key, 0) for key in axis_keys]
|
||||
|
||||
year_list = (
|
||||
Expense.objects.filter(owner=request.user)
|
||||
@ -931,6 +1002,9 @@ def dashboard(request):
|
||||
"by_category_chart": by_category_chart,
|
||||
"chart_labels": chart_labels,
|
||||
"chart_data": chart_totals,
|
||||
# La otra serie del gráfico de ingresos contra gastos. Se manda una
|
||||
# sola vez: el neto y los tres modos se resuelven en cliente.
|
||||
"chart_income_data": chart_income_totals,
|
||||
"chart_type": chart_type,
|
||||
"year_list": year_list,
|
||||
"months": list(range(1, 13)),
|
||||
@ -1166,7 +1240,7 @@ def income_list(request):
|
||||
|
||||
# La tabla sigue listando los traspasos, pero el total mide ingreso real y
|
||||
# los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla.
|
||||
totals = incomes.exclude(is_transfer=True).aggregate(
|
||||
totals = _without_transfers(incomes).aggregate(
|
||||
total=Sum("amount"), count=Count("id")
|
||||
)
|
||||
total_amount = totals["total"] or 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user