Modificación y mejora de objetivos #41
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -3,7 +3,7 @@ from decimal import Decimal
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Sum
|
from django.db.models import Q, Sum
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.db.models.functions import ExtractMonth, Lower
|
from django.db.models.functions import ExtractMonth, Lower
|
||||||
@ -551,17 +551,47 @@ class FuelEntry(models.Model):
|
|||||||
return None
|
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):
|
class Goal(models.Model):
|
||||||
KIND_PAYMENT = "payment"
|
KIND_PAYMENT = "payment"
|
||||||
KIND_BUDGET = "budget"
|
KIND_BUDGET = "budget"
|
||||||
KIND_SAVING = "saving"
|
KIND_SAVING = "saving"
|
||||||
|
KIND_COLLECTION = "collection"
|
||||||
|
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
(KIND_PAYMENT, "Pago / deuda"),
|
(KIND_PAYMENT, "Pago / deuda"),
|
||||||
(KIND_BUDGET, "Presupuesto"),
|
(KIND_BUDGET, "Presupuesto"),
|
||||||
(KIND_SAVING, "Ahorro"),
|
(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_NONE = "none"
|
||||||
PERIOD_MONTH = "month"
|
PERIOD_MONTH = "month"
|
||||||
PERIOD_YEAR = "year"
|
PERIOD_YEAR = "year"
|
||||||
@ -604,6 +634,57 @@ class Goal(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
show_on_home = models.BooleanField(default=False)
|
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):
|
def _period_start(self):
|
||||||
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
|
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
|
||||||
if self.kind == self.KIND_BUDGET:
|
if self.kind == self.KIND_BUDGET:
|
||||||
@ -632,25 +713,46 @@ class Goal(models.Model):
|
|||||||
# así que no filtres is_transfer en esta rama.
|
# 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 self.kind == self.KIND_COLLECTION:
|
||||||
return Decimal("0")
|
# 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:
|
return _deduped_total(self._since_start(incomes))
|
||||||
category_ids = self.category.descendant_ids()
|
|
||||||
else:
|
category_ids = []
|
||||||
category_ids = [self.category_id]
|
# 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
|
# Pago y presupuesto miden gasto real, así que los traspasos entre
|
||||||
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
|
||||||
expenses = Expense.objects.filter(
|
expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude(
|
||||||
owner=self.owner, category_id__in=category_ids
|
is_transfer=True
|
||||||
).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()
|
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):
|
def percentage(self):
|
||||||
if not self.target_amount:
|
if not self.target_amount:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user