From 4167338a6ae23ded1059d79ce2fbbc086157ea98 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:47 +0200 Subject: [PATCH] Add goal-movement links and collection goal type --- ...d_expenses_goal_linked_incomes_and_more.py | 28 ++++ expenses_manager/expenses/models.py | 128 ++++++++++++++++-- 2 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py diff --git a/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py new file mode 100644 index 0000000..4602438 --- /dev/null +++ b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py @@ -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), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index b6d763b..6ac2249 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -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: