Add goal-movement links and collection goal type

This commit is contained in:
JKuijperM 2026-10-13 13:37:47 +02:00
parent 982f654cb5
commit 4167338a6a
2 changed files with 143 additions and 13 deletions

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.10 on 2026-10-08 14:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0016_alter_transfer_expense_leg_alter_transfer_income_leg'),
]
operations = [
migrations.AddField(
model_name='goal',
name='linked_expenses',
field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.expense', verbose_name='Gastos vinculados'),
),
migrations.AddField(
model_name='goal',
name='linked_incomes',
field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.income', verbose_name='Ingresos vinculados'),
),
migrations.AlterField(
model_name='goal',
name='kind',
field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro'), ('collection', 'Cobro pendiente')], default='payment', max_length=10),
),
]

View File

@ -3,7 +3,7 @@ from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
from django.conf import settings
from django.db.models import Sum
from django.db.models import Q, Sum
from functools import cached_property
from django.utils.text import slugify
from django.db.models.functions import ExtractMonth, Lower
@ -551,17 +551,47 @@ class FuelEntry(models.Model):
return None
def _deduped_total(movements):
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro
suma cada movimiento tantas veces como objetivos tenga vinculados, y
.distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT
final y en un aggregate no hay filas que deduplicar, porque el JOIN ya
multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor,
porque deduplica por el valor del importe: dos gastos distintos de 10 € se
sumarían como uno.
Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El
`pk__in` con un queryset dentro es una subconsulta, no un segundo viaje.
"""
ids = movements.values("pk").distinct()
total = movements.model.objects.filter(pk__in=ids).aggregate(
total=Sum("amount")
)["total"]
return total or Decimal("0")
class Goal(models.Model):
KIND_PAYMENT = "payment"
KIND_BUDGET = "budget"
KIND_SAVING = "saving"
KIND_COLLECTION = "collection"
KIND_CHOICES = [
(KIND_PAYMENT, "Pago / deuda"),
(KIND_BUDGET, "Presupuesto"),
(KIND_SAVING, "Ahorro"),
(KIND_COLLECTION, "Cobro pendiente"),
]
# Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de
# marcado (el formulario del movimiento y la pantalla del objetivo) leen de
# aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo.
KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET)
KINDS_LINKING_INCOMES = (KIND_COLLECTION,)
PERIOD_NONE = "none"
PERIOD_MONTH = "month"
PERIOD_YEAR = "year"
@ -604,6 +634,57 @@ class Goal(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
show_on_home = models.BooleanField(default=False)
# Movimientos sueltos vinculados a mano. Conviven con la selección por
# categoría, no la sustituyen: un objetivo puede tirar de categoría, de
# movimientos sueltos, o de los dos a la vez.
#
# blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la
# tabla intermedia y no toca los movimientos, que es lo que se quiere.
linked_expenses = models.ManyToManyField(
"Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados"
)
linked_incomes = models.ManyToManyField(
"Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados"
)
@property
def links_incomes(self):
"""Si este objetivo se mide con ingresos en vez de con gastos."""
return self.kind in self.KINDS_LINKING_INCOMES
@property
def links(self):
"""El M2M que le toca a este objetivo, para no repetir el if por ahí."""
return self.linked_incomes if self.links_incomes else self.linked_expenses
@property
def accepts_links(self):
"""Si vincularle movimientos significa algo.
El ahorro no: se mide con el saldo de su cuenta, así que un gasto
vinculado ahí quedaría guardado y no contaría en el progreso.
"""
return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES
def candidate_movements(self):
"""Movimientos que este objetivo puede vincular.
Único sitio donde se decide qué es vinculable; lo usan las dos vías de
marcado. Fuera las patas de un traspaso: mover dinero entre cuentas
propias no es ni un pago ni un cobro. La comisión sí entra, porque es
gasto real (va con is_transfer=False) y cuenta en los análisis.
"""
model = Income if self.links_incomes else Expense
movements = model.objects.filter(owner_id=self.owner_id).exclude(
is_transfer=True
)
start = self._period_start()
if start:
movements = movements.filter(date__gte=start)
return movements
def _period_start(self):
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
if self.kind == self.KIND_BUDGET:
@ -632,25 +713,46 @@ class Goal(models.Model):
# así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0")
if not self.category:
return Decimal("0")
if self.kind == self.KIND_COLLECTION:
# Un cobro pendiente mide lo que te han devuelto, así que suma los
# ingresos vinculados a mano. Aquí no hay selección por categoría
# que valga: Income no tiene categoría, que es justo el motivo de
# que exista la vinculación.
incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude(
is_transfer=True
)
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
return _deduped_total(self._since_start(incomes))
category_ids = []
# Por _id y no por el objeto: mirar self.category traería la categoría
# de la base de datos, una consulta por objetivo al pintar una lista.
if self.category_id:
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
# Un solo queryset con un OR, y no dos sumas por separado: un gasto que
# esté en la categoría del objetivo Y además vinculado a mano se
# contaría dos veces. No daría error, daría un número mal.
scope = Q(goals=self)
if category_ids:
scope |= Q(category_id__in=category_ids)
# 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(
owner=self.owner, category_id__in=category_ids
).exclude(is_transfer=True)
expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude(
is_transfer=True
)
return _deduped_total(self._since_start(expenses))
def _since_start(self, movements):
"""Acota al periodo del objetivo. El cobro pendiente lo necesita igual."""
start = self._period_start()
if start:
expenses = expenses.filter(date__gte=start)
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
return movements.filter(date__gte=start) if start else movements
def percentage(self):
if not self.target_amount: