"""Fälligkeiten: virtuelle Expansion über alle Posten sowie Bestätigen und Auslassen.""" from collections.abc import Sequence from dataclasses import dataclass from datetime import date, timedelta from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.errors import NotFoundError, ValidationError from app.models import Occurrence, Recurrence from app.models.enums import EntryKind, OccurrenceStatus from app.services.recurrence import ( PlannedOccurrence, expand, expand_by_due_date, resolve_amount, ) # Overlays werden mit Puffer geladen, weil Ist-Datum und Soll-Datum auseinanderliegen können. _OVERLAY_PADDING_DAYS = timedelta(days=45) @dataclass(frozen=True, slots=True) class DueItem: """Eine Fälligkeit samt der Recurrence, aus der sie stammt.""" planned: PlannedOccurrence recurrence: Recurrence async def load_recurrences( session: AsyncSession, *, kind: EntryKind | None = None, only_active: bool = True, category_id: int | None = None, account_id: int | None = None, recurrence_id: int | None = None, ) -> list[Recurrence]: """Lädt Posten samt Preishistorie in einem Rutsch.""" stmt = ( select(Recurrence) .options(selectinload(Recurrence.amount_versions)) .order_by(Recurrence.title) ) if only_active: stmt = stmt.where(Recurrence.is_active.is_(True)) if kind is not None: stmt = stmt.where(Recurrence.kind == kind) if category_id is not None: stmt = stmt.where(Recurrence.category_id == category_id) if account_id is not None: stmt = stmt.where(Recurrence.account_id == account_id) if recurrence_id is not None: stmt = stmt.where(Recurrence.id == recurrence_id) return list((await session.execute(stmt)).scalars().all()) async def load_overlays( session: AsyncSession, recurrence_ids: Sequence[int], date_from: date, date_to: date, ) -> dict[int, list[Occurrence]]: """Materialisierte Fälligkeiten im Zeitfenster, nach Recurrence gruppiert. Das Fenster wird großzügig gewählt, weil Ist-Datum und nominales Datum auseinanderliegen können. """ if not recurrence_ids: return {} stmt = select(Occurrence).where( Occurrence.recurrence_id.in_(recurrence_ids), Occurrence.occurrence_date >= date_from, Occurrence.occurrence_date <= date_to, ) grouped: dict[int, list[Occurrence]] = {} for row in (await session.execute(stmt)).scalars(): grouped.setdefault(row.recurrence_id, []).append(row) return grouped async def due_items( session: AsyncSession, date_from: date, date_to: date, *, by_due_date: bool = True, kind: EntryKind | None = None, only_active: bool = True, category_id: int | None = None, account_id: int | None = None, recurrence_id: int | None = None, ) -> list[DueItem]: """Expandiert alle passenden Posten über das Zeitfenster. `by_due_date=True` gruppiert nach dem tatsächlichen Zahltag – die richtige Sicht für Kalender und Monatsauswertungen. `False` filtert nach dem nominalen Datum. """ recurrences = await load_recurrences( session, kind=kind, only_active=only_active, category_id=category_id, account_id=account_id, recurrence_id=recurrence_id, ) if not recurrences: return [] # Overlays großzügig laden: eine Zahlung kann Wochen nach dem Soll erfasst werden. padding = _OVERLAY_PADDING_DAYS overlays = await load_overlays( session, [item.id for item in recurrences], date_from - padding, date_to + padding, ) expander = expand_by_due_date if by_due_date else expand items: list[DueItem] = [] for recurrence in recurrences: planned = expander( recurrence, date_from, date_to, amount_versions=recurrence.amount_versions, occurrences=overlays.get(recurrence.id, []), ) items.extend(DueItem(planned=entry, recurrence=recurrence) for entry in planned) items.sort(key=lambda item: (item.planned.effective_date, item.recurrence.title)) return items async def get_recurrence(session: AsyncSession, recurrence_id: int) -> Recurrence: stmt = ( select(Recurrence) .options(selectinload(Recurrence.amount_versions)) .where(Recurrence.id == recurrence_id) ) recurrence = (await session.execute(stmt)).scalar_one_or_none() if recurrence is None: raise NotFoundError( f"Der wiederkehrende Posten mit der ID {recurrence_id} existiert nicht." ) return recurrence async def _materialise( session: AsyncSession, recurrence: Recurrence, occurrence_date: date ) -> Occurrence: """Legt die Zeile für eine Fälligkeit an oder lädt die vorhandene. Es werden nur Termine akzeptiert, die die Wiederholungsregel tatsächlich liefert – das nominale Datum ist der Schlüssel. """ matches = expand( recurrence, occurrence_date, occurrence_date, amount_versions=recurrence.amount_versions, ) if not matches: raise ValidationError( f"Zum {occurrence_date.isoformat()} gibt es für '{recurrence.title}' keine Fälligkeit.", code="occurrence_not_due", ) stmt = select(Occurrence).where( Occurrence.recurrence_id == recurrence.id, Occurrence.occurrence_date == occurrence_date, ) existing = (await session.execute(stmt)).scalar_one_or_none() if existing is not None: return existing occurrence = Occurrence( recurrence_id=recurrence.id, occurrence_date=occurrence_date, status=OccurrenceStatus.PLANNED, planned_amount=matches[0].amount, ) session.add(occurrence) await session.flush() return occurrence async def confirm( session: AsyncSession, recurrence_id: int, occurrence_date: date, *, actual_amount: Decimal | None = None, actual_date: date | None = None, account_id: int | None = None, note: str | None = None, ) -> Occurrence: """Bestätigt eine Fälligkeit, wahlweise mit abweichendem Betrag oder Datum.""" recurrence = await get_recurrence(session, recurrence_id) occurrence = await _materialise(session, recurrence, occurrence_date) occurrence.status = OccurrenceStatus.CONFIRMED occurrence.planned_amount = resolve_amount( recurrence, occurrence_date, recurrence.amount_versions ) occurrence.actual_amount = ( actual_amount if actual_amount is not None else occurrence.planned_amount ) occurrence.actual_date = actual_date if account_id is not None: occurrence.account_id = account_id if note is not None: occurrence.note = note await session.flush() return occurrence async def skip( session: AsyncSession, recurrence_id: int, occurrence_date: date, *, note: str | None = None, ) -> Occurrence: """Markiert eine Fälligkeit als ausgefallen.""" recurrence = await get_recurrence(session, recurrence_id) occurrence = await _materialise(session, recurrence, occurrence_date) occurrence.status = OccurrenceStatus.SKIPPED occurrence.actual_amount = None occurrence.actual_date = None if note is not None: occurrence.note = note await session.flush() return occurrence async def reset(session: AsyncSession, recurrence_id: int, occurrence_date: date) -> None: """Nimmt Bestätigung oder Auslassung zurück und entfernt die materialisierte Zeile.""" stmt = select(Occurrence).where( Occurrence.recurrence_id == recurrence_id, Occurrence.occurrence_date == occurrence_date, ) occurrence = (await session.execute(stmt)).scalar_one_or_none() if occurrence is None: raise NotFoundError( f"Zum {occurrence_date.isoformat()} ist keine abweichende Fälligkeit erfasst." ) await session.delete(occurrence) await session.flush()