Excluye los traspasos de los calculos de analisis

Un traspaso mueve dinero entre cuentas propias: suma al saldo de cada cuenta
pero no es gasto ni ingreso real. Las agregaciones de analisis (KPIs, graficos
de home y dashboard, comparativa de periodos, totales del listado y objetivos
de pago y presupuesto) pasan a excluirlo.

Hay dos comportamientos opuestos sobre el mismo campo y conviene no
confundirlos:

- Analisis: excluye los traspasos. De ahi el helper _analysis_expenses, que
  documenta en su docstring donde NO debe usarse.
- Saldo: los incluye. _account_balances, current_balance, monthly_balance,
  balance_until y monthly_net quedan intactos a proposito; filtrarlos ahi
  descuadraria todos los saldos de la aplicacion de forma silenciosa, que es
  mucho peor y menos evidente que inflar un KPI.

Por el mismo motivo la rama saving de Goal.progress tampoco filtra: mandar
dinero a la cuenta de ahorro es justo como se progresa en ese objetivo.

Los ultimos movimientos de home dejan de colgar de la consulta de KPIs y usan
la suya propia: son un listado, no un analisis, y un traspaso tiene que poder
verse ahi. Lo mismo en el listado de gastos, donde la tabla los sigue
mostrando y solo los totales los descuentan.

Los managers por defecto de Expense e Income no se tocan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-16 08:30:43 +02:00
parent 8c0225a297
commit e2b756bcc7
3 changed files with 48 additions and 12 deletions

View File

@ -391,6 +391,9 @@ class Goal(models.Model):
if self.kind == self.KIND_SAVING: if self.kind == self.KIND_SAVING:
# Provisional: saldo de la cuenta asociada. Cuando exista el # Provisional: saldo de la cuenta asociada. Cuando exista el
# módulo de inversiones, esta rama es lo único que hay que tocar. # módulo de inversiones, esta rama es lo único que hay que tocar.
# Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
# ahorro es precisamente cómo se progresa en un objetivo de ahorro,
# así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0") return self.account.current_balance() if self.account else Decimal("0")
if not self.category: if not self.category:
@ -401,9 +404,11 @@ class Goal(models.Model):
else: else:
category_ids = [self.category_id] category_ids = [self.category_id]
# Pago y presupuesto miden gasto real, así que los traspasos entre
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
expenses = Expense.objects.filter( expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids owner=self.owner, category_id__in=category_ids
) ).exclude(is_transfer=True)
start = self._period_start() start = self._period_start()
if start: if start:

View File

@ -96,7 +96,7 @@
<br> <br>
<section> <section>
<strong>Total:</strong> {{ kpi_total|floatformat:2 }}€ | <strong>Total (sin traspasos):</strong> {{ kpi_total|floatformat:2 }}€ |
<strong>Gastos:</strong> {{ kpi_count }} | <strong>Gastos:</strong> {{ kpi_count }} |
<strong>Categorías:</strong> {{ kpi_categories }} <strong>Categorías:</strong> {{ kpi_categories }}
</section> </section>

View File

@ -77,9 +77,26 @@ def _category_tree(categories):
return rows return rows
def _analysis_expenses(user):
"""Gastos para análisis: excluye los traspasos entre cuentas propias.
Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de
cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y
comparativas.
NO lo uses en cálculos de saldo (_account_balances, current_balance,
monthly_balance, balance_until, monthly_net): ahí los traspasos cuentan,
y filtrarlos descuadraría todos los saldos.
"""
return Expense.objects.filter(owner=user).exclude(is_transfer=True)
def _account_balances(accounts): def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de """Calcula el saldo de cada cuenta con dos queries agregadas (nada de
account.current_balance() en bucle, que dispararía N+1).""" account.current_balance() en bucle, que dispararía N+1).
Los traspasos cuentan aquí a propósito: son movimientos reales de las
cuentas. No metas _analysis_expenses en esta función."""
account_ids = [a.id for a in accounts] account_ids = [a.id for a in accounts]
expense_totals = { expense_totals = {
@ -176,7 +193,9 @@ def _build_account_charts(accounts, balance_by_account, selected_year, today):
@login_required @login_required
def home(request): def home(request):
today = date.today() today = date.today()
expenses = Expense.objects.filter(owner=request.user) # Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos.
# Los últimos movimientos usan su propia query, que sí los incluye.
expenses = _analysis_expenses(request.user)
# ---- KPIs del mes en curso ---- # ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month) month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
@ -214,9 +233,14 @@ def home(request):
has_alerts = bool(exceeded_goals or negative_accounts) has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ---- # ---- Últimos movimientos (gastos + ingresos mezclados) ----
recent_expenses = expenses.select_related("category", "account").order_by( # Es un listado, no un análisis: los traspasos son movimientos reales de las
"-date", "-id" # cuentas y salen marcados con un distintivo, así que no se cuelga de
)[:8] # "expenses" (que ya los ha filtrado) sino de la query sin filtrar.
recent_expenses = (
Expense.objects.filter(owner=request.user)
.select_related("category", "account")
.order_by("-date", "-id")[:8]
)
recent_incomes = ( recent_incomes = (
Income.objects.filter(owner=request.user) Income.objects.filter(owner=request.user)
.select_related("account") .select_related("account")
@ -329,11 +353,16 @@ def expense_list(request):
expenses = expenses.order_by("-date") expenses = expenses.order_by("-date")
total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0 # La tabla sigue listando los traspasos (hay que poder verlos y corregirlos),
# pero el total y los contadores miden gasto real, así que los excluyen. Por
# eso la etiqueta del total dice "sin traspasos" en la plantilla.
analysis_expenses = expenses.exclude(is_transfer=True)
expense_count = expenses.count() total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0
category_count = expenses.values("category").distinct().count() expense_count = analysis_expenses.count()
category_count = analysis_expenses.values("category").distinct().count()
# Pagination # Pagination
paginator = Paginator(expenses, 10) paginator = Paginator(expenses, 10)
@ -490,7 +519,9 @@ def dashboard(request):
kpi_balance = total_balance kpi_balance = total_balance
# Filter by base expenses # Filter by base expenses
expenses = Expense.objects.filter(owner=request.user) # Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así
# que va sin traspasos. Los saldos de arriba sí los incluyen.
expenses = _analysis_expenses(request.user)
if account_id: if account_id:
expenses = expenses.filter(account_id=account_id) expenses = expenses.filter(account_id=account_id)
@ -576,7 +607,7 @@ def dashboard(request):
category_comparison = [] category_comparison = []
if compare_enabled: if compare_enabled:
previous_expenses = Expense.objects.filter(owner=request.user) previous_expenses = _analysis_expenses(request.user)
if account_id: if account_id:
previous_expenses = previous_expenses.filter(account_id=account_id) previous_expenses = previous_expenses.filter(account_id=account_id)