from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import Decimal from sqlalchemy import select from app.extensions import db from app.models import ( Account, Category, Entry, EntryShareRule, Month, MonthlyAllocation, MonthlyEntryValue, MonthlyIncome, to_decimal, ) from app.services.allocation_service import AllocationSuggestionService from app.services.comparison_service import ComparisonService from app.services.share_service import ShareCalculationService from app.utils.users import personal_account_names @dataclass class MonthSummary: total_income: Decimal fixed_costs: Decimal distribution_pool: Decimal total_costs: Decimal remainder: Decimal allocation_total: Decimal suggestion_total: Decimal current_allocations: list suggestions: list deltas: dict top_changes: list external_totals: list class MonthService: DISTRIBUTION_ENTRY_PREFERENCES = { "sparen": "Sparziel", "urlaub": "Reisebudget", "freizeit": "Freizeitbudget", "persoenlich-flo": "Person 1", "persoenlich-desi": "Person 2", } def __init__( self, allocation_service: AllocationSuggestionService, comparison_service: ComparisonService, share_service: ShareCalculationService, ): self.allocation_service = allocation_service self.comparison_service = comparison_service self.share_service = share_service def ensure_month(self, target: date | None = None) -> Month: target = target or date.today() label = f"{target.year:04d}-{target.month:02d}" month = Month.query.filter_by(label=label).first() if month: return month latest = Month.query.order_by(Month.year.desc(), Month.month.desc()).first() if latest: # Neue Monate erben den letzten gepflegten Stand, damit der Alltag # mit einem realistischen Ausgangspunkt startet statt mit leeren Formularen. month = self.copy_month(latest, target.year, target.month, auto_created=True) else: # Beim allerersten Start legen wir einen nutzbaren Seed-Monat mit # Standardkonten, Einkommenszeilen und Defaultwerten an. month = self.seed_initial_month(target.year, target.month) db.session.commit() return month def seed_initial_month(self, year: int, month_num: int) -> Month: month = Month( label=f"{year:04d}-{month_num:02d}", year=year, month=month_num, auto_created=True, savings_min_pct=Decimal("15.00"), savings_max_pct=Decimal("20.00"), vacation_min_pct=Decimal("5.00"), vacation_max_pct=Decimal("8.00"), leisure_min_pct=Decimal("5.00"), leisure_max_pct=Decimal("10.00"), personal_split_desi_pct=Decimal(str(self.allocation_service.default_personal_split_desi_pct)), ) db.session.add(month) db.session.flush() entries = db.session.scalars(select(Entry).where(Entry.is_active.is_(True))).all() for entry in entries: db.session.add( MonthlyEntryValue( month_id=month.id, entry_id=entry.id, planned_amount=to_decimal(entry.default_amount), ) ) self._create_default_income_lines(month) self._ensure_allocations(month) return month def copy_month( self, source_month: Month, year: int, month_num: int, auto_created: bool = False ) -> Month: label = f"{year:04d}-{month_num:02d}" month = Month( label=label, year=year, month=month_num, auto_created=auto_created, savings_min_pct=source_month.savings_min_pct, savings_max_pct=source_month.savings_max_pct, vacation_min_pct=source_month.vacation_min_pct, vacation_max_pct=source_month.vacation_max_pct, leisure_min_pct=source_month.leisure_min_pct, leisure_max_pct=source_month.leisure_max_pct, personal_split_desi_pct=source_month.personal_split_desi_pct, ) db.session.add(month) db.session.flush() for value in source_month.entry_values: db.session.add( MonthlyEntryValue( month_id=month.id, entry_id=value.entry_id, planned_amount=value.planned_amount, note=value.note, created_by=value.created_by, updated_by=value.updated_by, ) ) for income in source_month.incomes: db.session.add( MonthlyIncome( month_id=month.id, label=income.label, amount=income.amount, sort_order=income.sort_order, ) ) for allocation in source_month.allocations: db.session.add( MonthlyAllocation( month_id=month.id, target_account_id=allocation.target_account_id, label=allocation.label, amount=allocation.amount, source=allocation.source, is_locked=allocation.is_locked, sort_order=allocation.sort_order, ) ) self._ensure_allocations(month) return month def get_or_create_by_label(self, label: str) -> Month: year, month_num = [int(part) for part in label.split("-")] month = Month.query.filter_by(label=label).first() if month: return month previous = self.previous_month(year, month_num) if previous: month = self.copy_month(previous, year, month_num, auto_created=True) else: month = self.seed_initial_month(year, month_num) db.session.commit() return month def previous_month(self, year: int, month_num: int) -> Month | None: stmt = ( select(Month) .where((Month.year < year) | ((Month.year == year) & (Month.month < month_num))) .order_by(Month.year.desc(), Month.month.desc()) ) return db.session.scalars(stmt).first() def compute_summary(self, month: Month) -> MonthSummary: total_income = sum((to_decimal(item.amount) for item in month.incomes), Decimal("0.00")) distribution_entry_values = self._distribution_entry_values(month) distribution_entry_ids = {value.entry_id for value in distribution_entry_values.values()} fixed_costs = sum( ( self.share_service.calculate_entry_shares(item)["internal_total"] for item in month.entry_values if item.entry_id not in distribution_entry_ids ), Decimal("0.00"), ) allocation_total = sum((to_decimal(item.amount) for item in month.allocations), Decimal("0.00")) total_costs = fixed_costs + allocation_total distribution_pool = total_income - fixed_costs remainder = distribution_pool - allocation_total suggestions = month.suggestions allocation_amounts = { item.target_account_id: to_decimal(item.amount) for item in month.allocations if item.target_account_id is not None } suggestion_total = sum( ( max( Decimal("0.00"), to_decimal(item.suggested_amount) - allocation_amounts.get(item.target_account_id, Decimal("0.00")), ) for item in suggestions ), Decimal("0.00"), ) previous = self.previous_month(month.year, month.month) deltas = self.comparison_service.month_delta(month, previous) top_changes = self.comparison_service.top_entry_changes(month, previous) external_totals = self.share_service.calculate_external_month_totals(month) return MonthSummary( total_income=total_income, fixed_costs=fixed_costs, distribution_pool=distribution_pool, total_costs=total_costs, remainder=remainder, allocation_total=allocation_total, suggestion_total=suggestion_total, current_allocations=sorted(month.allocations, key=lambda item: item.sort_order), suggestions=sorted(suggestions, key=lambda item: item.target_account.sort_order), deltas=deltas, top_changes=top_changes, external_totals=external_totals, ) def refresh_suggestions(self, month: Month, reason: str = "") -> list: summary = self.compute_summary(month) self.apply_personal_remainder(month, summary.distribution_pool) db.session.flush() summary = self.compute_summary(month) # Vorschläge basieren auf dem gesamten Verteilungstopf nach Fixkosten. # So bleiben die Richtwerte stabil, auch wenn bereits manuelle Werte in # Sparen, Urlaub, Freizeit oder persönlicher Auszahlung stehen. suggestions = self.allocation_service.recompute( month, summary.distribution_pool, summary.total_income, reason=reason or "Aktueller Restbetrag wurde neu berechnet", ) db.session.flush() return suggestions def apply_personal_remainder(self, month: Month, distribution_pool: Decimal) -> bool: allocations_by_slug = { item.target_account.slug: item for item in month.allocations if item.target_account and item.target_account.slug in self.allocation_service.TARGET_SLUGS } committed_priority_total = sum( ( to_decimal(allocations_by_slug.get(slug).amount) for slug in ("sparen", "urlaub", "freizeit") if allocations_by_slug.get(slug) is not None ), Decimal("0.00"), ) remaining_pool = max(Decimal("0.00"), to_decimal(distribution_pool) - committed_priority_total) desi_pct = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(month.personal_split_desi_pct))) flo_pct = Decimal("100.00") - desi_pct flo_amount = (remaining_pool * flo_pct / Decimal("100.00")).quantize(Decimal("0.01")) desi_amount = remaining_pool - flo_amount changed = False target_values = { "persoenlich-flo": flo_amount, "persoenlich-desi": desi_amount, } for slug, target_amount in target_values.items(): allocation = allocations_by_slug.get(slug) if allocation is None: continue if to_decimal(allocation.amount) != to_decimal(target_amount): allocation.amount = to_decimal(target_amount) allocation.source = "remainder_auto" changed = True if changed: self.sync_distribution_entries_from_allocations(month) return changed def sync_distribution_entries_from_allocations(self, month: Month) -> bool: changed = False distribution_values = self._distribution_entry_values(month) allocations_by_slug = { item.target_account.slug: item for item in month.allocations if item.target_account and item.target_account.slug in self.allocation_service.TARGET_SLUGS } for account_slug, allocation in allocations_by_slug.items(): value = distribution_values.get(account_slug) if value is None: continue allocation_amount = to_decimal(allocation.amount) if to_decimal(value.planned_amount) != allocation_amount: value.planned_amount = allocation_amount changed = True display_label = self._distribution_label(account_slug, value.entry.name) if allocation.label != display_label: allocation.label = display_label changed = True return changed def sync_distribution_allocation_from_entry( self, month: Month, entry: Entry, mark_manual: bool = False ) -> bool: account = entry.category.account if entry.category else None if account is None or account.slug not in self.allocation_service.TARGET_SLUGS: return False value = next((item for item in month.entry_values if item.entry_id == entry.id), None) if value is None: return False allocation = next( ( item for item in month.allocations if item.target_account and item.target_account.slug == account.slug ), None, ) if allocation is None: return False changed = False entry_amount = to_decimal(value.planned_amount) if to_decimal(allocation.amount) != entry_amount: allocation.amount = entry_amount changed = True display_label = self._distribution_label(account.slug, entry.name) if allocation.label != display_label: allocation.label = display_label changed = True if mark_manual and changed: allocation.source = "manual" return changed def _create_default_income_lines(self, month: Month) -> None: labels = ["Einkommen 1", "Einkommen 2"] for sort_order, label in enumerate(labels, start=1): db.session.add( MonthlyIncome(month_id=month.id, label=label, amount=Decimal("0.00"), sort_order=sort_order) ) def _ensure_allocations(self, month: Month) -> None: target_accounts = self.allocation_service._target_accounts() existing = {item.target_account_id for item in month.allocations} for account in target_accounts: if account.id in existing: continue db.session.add( MonthlyAllocation( month_id=month.id, target_account_id=account.id, label=self._distribution_label(account.slug, account.name), amount=Decimal("0.00"), source="manual", sort_order=account.sort_order, ) ) def _distribution_entry_values(self, month: Month) -> dict[str, MonthlyEntryValue]: grouped: dict[str, list[MonthlyEntryValue]] = {} for value in month.entry_values: entry = value.entry category = entry.category if entry else None account = category.account if category else None if ( entry is None or category is None or account is None or not entry.is_active or not category.is_active or not account.is_active or account.slug not in self.allocation_service.TARGET_SLUGS or not entry.is_allocation_target ): continue grouped.setdefault(account.slug, []).append(value) preferred = {} for account_slug, values in grouped.items(): preferred_name = self.DISTRIBUTION_ENTRY_PREFERENCES.get(account_slug) values.sort( key=lambda item: ( item.entry.name != preferred_name, item.entry.sort_order, item.entry.id, ) ) preferred[account_slug] = values[0] return preferred def _distribution_label(self, account_slug: str, fallback: str) -> str: personal_labels = personal_account_names() if account_slug in personal_labels: return personal_labels[account_slug] return fallback def normalize_personal_split(self, month: Month, flo_pct: object, desi_pct: object) -> None: flo_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(flo_pct))) desi_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(desi_pct))) total = flo_value + desi_value if total == Decimal("100.00"): month.personal_split_desi_pct = desi_value return month.personal_split_desi_pct = Decimal("100.00") - flo_value def update_target_range(self, month: Month, slug: str, min_pct: object, max_pct: object) -> None: field_map = { "sparen": ("savings_min_pct", "savings_max_pct"), "urlaub": ("vacation_min_pct", "vacation_max_pct"), "freizeit": ("leisure_min_pct", "leisure_max_pct"), } target_fields = field_map.get(slug) if target_fields is None: return min_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(min_pct))) max_value = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(max_pct))) if min_value > max_value: min_value, max_value = max_value, min_value setattr(month, target_fields[0], min_value) setattr(month, target_fields[1], max_value)