430 lines
17 KiB
Python
430 lines
17 KiB
Python
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)
|