Constraint and query pass: FK delete rules, grouped queries, tiebreakers

- FK rules via migration 7884505b16b3 (verified on dev with a rolled-
  back probe): transactions/recurring items SET NULL on category delete
  (this also fixes the 500 that DELETE /categories raised for in-use
  categories), water readings CASCADE with their receipt. Models declare
  ondelete to stay in sync. (ARCH-09, BE-11)
- compute_actuals_by_source: 3 grouped queries replace 10 single-
  aggregate round-trips; behavior pinned by the existing cycle suite.
  (BE-21)
- compute_cc_by_category prefetches category names (ARCH-15); agent
  pension latest-per-fund resolved in SQL (BE-04); municipal water
  consumption batched into one grouped query (BE-05).
- Deterministic pagination: date DESC, id DESC on all transaction
  listings. (ARCH-12)
- New agent-tool tests (session binding, JSON-safe output, batched
  queries): 64 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 14:45:46 -06:00
parent 74886fbf98
commit 3cdd7d9eab
7 changed files with 316 additions and 128 deletions

View File

@@ -266,16 +266,27 @@ def get_pension_snapshots(
) -> list[dict]:
"""Pension fund snapshots. Each snapshot covers a period with balances,
contributions, returns, fees and the ending balance (saldo_final)."""
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
if latest_only:
# Latest snapshot per fund resolved in SQL instead of scanning all rows
latest = (
select(
PensionSnapshot.fund.label("fund"),
func.max(PensionSnapshot.period_end).label("period_end"),
)
.group_by(PensionSnapshot.fund)
.subquery()
)
q = select(PensionSnapshot).join(
latest,
(PensionSnapshot.fund == latest.c.fund)
& (PensionSnapshot.period_end == latest.c.period_end),
)
else:
q = select(PensionSnapshot)
q = q.order_by(col(PensionSnapshot.period_end).desc())
if fund:
q = q.where(PensionSnapshot.fund == fund)
rows = _s().exec(q).all()
if latest_only:
seen: dict[str, PensionSnapshot] = {}
for r in rows:
if r.fund.value not in seen:
seen[r.fund.value] = r
rows = list(seen.values())
return [
{
"fund": r.fund.value,
@@ -322,11 +333,21 @@ def get_municipal_receipts(
q = q.where(MunicipalReceipt.account == account)
q = q.limit(limit)
rows = _s().exec(q).all()
# One grouped query for all receipts instead of one per receipt
consumption: dict[int, float] = {}
ids = [r.id for r in rows if r.id is not None]
if ids:
grouped = _s().exec(
select(
WaterMeterReading.receipt_id,
func.sum(WaterMeterReading.consumption_m3),
)
.where(col(WaterMeterReading.receipt_id).in_(ids))
.group_by(WaterMeterReading.receipt_id)
).all()
consumption = {rid: float(total) for rid, total in grouped}
out: list[dict] = []
for r in rows:
readings = _s().exec(
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
).all()
out.append(
{
"id": r.id,
@@ -338,7 +359,7 @@ def get_municipal_receipts(
"interests": float(r.interests),
"iva": float(r.iva),
"total": float(r.total),
"water_consumption_m3": float(sum(w.consumption_m3 for w in readings)),
"water_consumption_m3": consumption.get(r.id, 0.0),
}
)
return out

View File

@@ -31,7 +31,7 @@ def list_salarios(
query = (
select(Transaction)
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
.order_by(col(Transaction.date).desc())
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
.offset(offset)
.limit(limit)
)

View File

@@ -93,7 +93,7 @@ def list_transactions(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
query = query.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).offset(offset).limit(limit)
return session.exec(query).all()
@@ -166,7 +166,7 @@ def recent_transactions(
query = (
select(Transaction)
.where(Transaction.source == TransactionSource.CREDIT_CARD)
.order_by(col(Transaction.date).desc())
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
.limit(limit)
)
return session.exec(query).all()

View File

@@ -157,7 +157,9 @@ class TransactionBase(SQLModel):
source: TransactionSource = TransactionSource.CREDIT_CARD
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
category_id: Optional[int] = Field(
default=None, foreign_key="category.id", ondelete="SET NULL"
)
deferred_to_next_cycle: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
@@ -273,7 +275,9 @@ class RecurringItemBase(SQLModel):
default=None,
sa_column=Column(JSON, nullable=True),
)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
category_id: Optional[int] = Field(
default=None, foreign_key="category.id", ondelete="SET NULL"
)
is_active: bool = True
notes: Optional[str] = None
@@ -470,7 +474,9 @@ class WaterMeterReadingBase(SQLModel):
alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
receipt_id: Optional[int] = Field(
default=None, foreign_key="municipalreceipt.id", ondelete="CASCADE"
)
class WaterMeterReading(WaterMeterReadingBase, table=True):

View File

@@ -124,115 +124,65 @@ def compute_actuals_by_source(
amount_crc = get_converted_amount_expr(session)
def _grouped(*where) -> dict[tuple, tuple[float, int]]:
"""{(source, type): (total_crc, count)} for matching transactions."""
rows = session.exec(
select(
Transaction.source,
Transaction.transaction_type,
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(*where)
.group_by(Transaction.source, Transaction.transaction_type)
).all()
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
# Three grouped queries replace the previous ten single-aggregate ones.
cc_now = _grouped(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
cc_deferred = _grouped(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
non_cc = _grouped(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
)
results = {}
for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
# Deferred from previous cycle
compra_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
devolucion = float(dev_normal) + float(dev_deferred)
count_normal = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
count_deferred = session.exec(
select(func.count()).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
count = count_normal + count_deferred
results[source.value] = {
"source": source.value,
"total_compra": compra,
"total_devolucion": devolucion,
"net": compra - devolucion,
"count": count,
}
buckets = (cc_now, cc_deferred)
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
).one()
compra_val = float(compra)
devolucion_val = float(devolucion)
results[source.value] = {
"source": source.value,
"total_compra": compra_val,
"total_devolucion": devolucion_val,
"net": compra_val - devolucion_val,
"count": count,
}
buckets = (non_cc,)
compra = sum(
b.get((source, TransactionType.COMPRA), (0.0, 0))[0] for b in buckets
)
devolucion = sum(
b.get((source, TransactionType.DEVOLUCION), (0.0, 0))[0]
for b in buckets
)
count = sum(
cnt
for b in buckets
for (s, t), (_total, cnt) in b.items()
if s == source and t not in INCOME_TYPES
)
results[source.value] = {
"source": source.value,
"total_compra": compra,
"total_devolucion": devolucion,
"net": compra - devolucion,
"count": count,
}
return results
@@ -380,18 +330,17 @@ def compute_cc_by_category(
).all()
)
# Resolve category names
# Resolve category names in one query instead of one per category
from app.models.models import Category
names = {
c.id: c.name for c in session.exec(select(Category)).all()
}
result = []
for cat_id, amount in totals.items():
if amount <= 0:
continue
if cat_id is not None:
cat = session.get(Category, cat_id)
name = cat.name if cat else "Sin categoría"
else:
name = "Sin categoría"
name = names.get(cat_id, "Sin categoría") if cat_id is not None else "Sin categoría"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)