release: publish saldo 0.1.0
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Account, AllocationSuggestion, MonthlyAllocation, to_decimal
|
||||
from app.utils.users import personal_account_names
|
||||
|
||||
|
||||
class AllocationSuggestionService:
|
||||
TARGET_SLUGS = ["sparen", "urlaub", "freizeit", "persoenlich-flo", "persoenlich-desi"]
|
||||
PRIORITY_TARGETS = ["sparen", "urlaub", "freizeit"]
|
||||
PERSONAL_TARGETS = ["persoenlich-flo", "persoenlich-desi"]
|
||||
|
||||
def __init__(self, target_rules: dict[str, dict], default_personal_split_desi_pct: float):
|
||||
self.target_rules = target_rules
|
||||
self.default_personal_split_desi_pct = default_personal_split_desi_pct
|
||||
|
||||
FIELD_MAP = {
|
||||
"sparen": ("savings_min_pct", "savings_max_pct", "Sparen"),
|
||||
"urlaub": ("vacation_min_pct", "vacation_max_pct", "Urlaub"),
|
||||
"freizeit": ("leisure_min_pct", "leisure_max_pct", "Freizeit"),
|
||||
}
|
||||
|
||||
def recompute(
|
||||
self,
|
||||
month,
|
||||
distributable_total: Decimal,
|
||||
total_income: Decimal,
|
||||
reason: str = "",
|
||||
) -> list[AllocationSuggestion]:
|
||||
distributable_total = max(Decimal("0.00"), to_decimal(distributable_total))
|
||||
total_income = max(Decimal("0.00"), to_decimal(total_income))
|
||||
targets = self._target_accounts()
|
||||
allocations = {item.target_account.slug: item for item in month.allocations if item.target_account}
|
||||
suggested_values = {slug: Decimal("0.00") for slug in self.TARGET_SLUGS}
|
||||
|
||||
for slug in self.PRIORITY_TARGETS:
|
||||
allocation = allocations.get(slug)
|
||||
current_amount = to_decimal(allocation.amount) if allocation is not None else Decimal("0.00")
|
||||
target_amount = self._minimum_target_amount(total_income, month, slug)
|
||||
suggested_values[slug] = max(current_amount, target_amount) if current_amount > target_amount else target_amount
|
||||
|
||||
for slug in self.PERSONAL_TARGETS:
|
||||
allocation = allocations.get(slug)
|
||||
if allocation is not None:
|
||||
suggested_values[slug] = to_decimal(allocation.amount)
|
||||
|
||||
AllocationSuggestion.query.filter_by(month_id=month.id).delete()
|
||||
suggestions = []
|
||||
for account in targets:
|
||||
rule = self.month_target_rule(month, account.slug)
|
||||
reason_text = reason or self._reason_for(account.slug, rule, month.personal_split_desi_pct)
|
||||
suggestion = AllocationSuggestion(
|
||||
month_id=month.id,
|
||||
target_account_id=account.id,
|
||||
suggested_amount=max(Decimal("0.00"), to_decimal(suggested_values.get(account.slug, 0))),
|
||||
reason=reason_text,
|
||||
strategy_key="income-targets-then-personal-split",
|
||||
)
|
||||
db.session.add(suggestion)
|
||||
suggestions.append(suggestion)
|
||||
db.session.flush()
|
||||
return suggestions
|
||||
|
||||
def accept_all(self, month) -> None:
|
||||
suggestions = {
|
||||
item.target_account_id: item
|
||||
for item in AllocationSuggestion.query.filter_by(month_id=month.id).all()
|
||||
}
|
||||
allocations = {
|
||||
item.target_account_id: item for item in MonthlyAllocation.query.filter_by(month_id=month.id).all()
|
||||
}
|
||||
for target_account in self._target_accounts():
|
||||
suggestion = suggestions.get(target_account.id)
|
||||
allocation = allocations.get(target_account.id)
|
||||
if not suggestion:
|
||||
continue
|
||||
if allocation is None:
|
||||
allocation = MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=target_account.id,
|
||||
label=target_account.name,
|
||||
sort_order=target_account.sort_order,
|
||||
source="accepted_suggestion",
|
||||
amount=suggestion.suggested_amount,
|
||||
)
|
||||
db.session.add(allocation)
|
||||
continue
|
||||
if allocation.is_locked:
|
||||
continue
|
||||
allocation.amount = suggestion.suggested_amount
|
||||
allocation.source = "accepted_suggestion"
|
||||
|
||||
def accept_single(self, month, account_id: int) -> None:
|
||||
suggestion = AllocationSuggestion.query.filter_by(
|
||||
month_id=month.id, target_account_id=account_id
|
||||
).first()
|
||||
if not suggestion:
|
||||
return
|
||||
allocation = MonthlyAllocation.query.filter_by(
|
||||
month_id=month.id, target_account_id=account_id
|
||||
).first()
|
||||
if allocation is None:
|
||||
account = db.session.get(Account, account_id)
|
||||
allocation = MonthlyAllocation(
|
||||
month_id=month.id,
|
||||
target_account_id=account_id,
|
||||
label=account.name,
|
||||
amount=suggestion.suggested_amount,
|
||||
source="accepted_suggestion",
|
||||
sort_order=account.sort_order,
|
||||
)
|
||||
db.session.add(allocation)
|
||||
return
|
||||
if not allocation.is_locked:
|
||||
allocation.amount = suggestion.suggested_amount
|
||||
allocation.source = "accepted_suggestion"
|
||||
|
||||
def strategy_hint(self, month, slug: str) -> dict[str, str | Decimal] | None:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return None
|
||||
return {
|
||||
"label": rule.get("label", slug.title()),
|
||||
"range_label": f"{int(rule['min_pct'])} bis {int(rule['max_pct'])} %",
|
||||
"min_pct": to_decimal(rule["min_pct"]),
|
||||
"max_pct": to_decimal(rule["max_pct"]),
|
||||
}
|
||||
|
||||
def month_target_rule(self, month, slug: str) -> dict | None:
|
||||
field_map = self.FIELD_MAP.get(slug)
|
||||
base_rule = self.target_rules.get(slug)
|
||||
if field_map is None or base_rule is None:
|
||||
return base_rule
|
||||
min_field, max_field, label = field_map
|
||||
min_pct = to_decimal(getattr(month, min_field, Decimal(str(base_rule["min_pct"] * 100))))
|
||||
max_pct = to_decimal(getattr(month, max_field, Decimal(str(base_rule["max_pct"] * 100))))
|
||||
return {
|
||||
"label": label,
|
||||
"min_pct": min_pct,
|
||||
"max_pct": max_pct,
|
||||
"recommended_pct": ((min_pct + max_pct) / Decimal("2.00")).quantize(Decimal("0.01")),
|
||||
}
|
||||
|
||||
def _target_accounts(self) -> list[Account]:
|
||||
stmt = (
|
||||
select(Account)
|
||||
.where(Account.slug.in_(self.TARGET_SLUGS), Account.is_active.is_(True))
|
||||
.order_by(Account.sort_order.asc(), Account.id.asc())
|
||||
)
|
||||
return list(db.session.scalars(stmt))
|
||||
|
||||
def _target_amount(self, total_income: Decimal, month, slug: str) -> Decimal:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return Decimal("0.00")
|
||||
recommended_pct = to_decimal(rule["recommended_pct"]) / Decimal("100.00")
|
||||
return (total_income * recommended_pct).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
def _minimum_target_amount(self, total_income: Decimal, month, slug: str) -> Decimal:
|
||||
rule = self.month_target_rule(month, slug)
|
||||
if rule is None:
|
||||
return Decimal("0.00")
|
||||
min_pct = to_decimal(rule["min_pct"]) / Decimal("100.00")
|
||||
return (total_income * min_pct).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
def _personal_split_values(
|
||||
self,
|
||||
remaining_pool: Decimal,
|
||||
desi_pct: Decimal,
|
||||
unlocked_targets: list[str],
|
||||
) -> dict[str, Decimal]:
|
||||
if remaining_pool <= Decimal("0.00") or not unlocked_targets:
|
||||
return {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
|
||||
desi_pct = min(Decimal("100.00"), max(Decimal("0.00"), to_decimal(desi_pct)))
|
||||
flo_pct = Decimal("100.00") - desi_pct
|
||||
weights = {
|
||||
"persoenlich-flo": flo_pct,
|
||||
"persoenlich-desi": desi_pct,
|
||||
}
|
||||
unlocked_weight_total = sum((weights[slug] for slug in unlocked_targets), Decimal("0.00"))
|
||||
if unlocked_weight_total <= Decimal("0.00"):
|
||||
even_share = (remaining_pool / Decimal(len(unlocked_targets))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
result = {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
assigned = Decimal("0.00")
|
||||
for index, slug in enumerate(unlocked_targets):
|
||||
if index == len(unlocked_targets) - 1:
|
||||
result[slug] = remaining_pool - assigned
|
||||
else:
|
||||
result[slug] = even_share
|
||||
assigned += even_share
|
||||
return result
|
||||
|
||||
result = {slug: Decimal("0.00") for slug in self.PERSONAL_TARGETS}
|
||||
assigned = Decimal("0.00")
|
||||
for index, slug in enumerate(unlocked_targets):
|
||||
if index == len(unlocked_targets) - 1:
|
||||
amount = remaining_pool - assigned
|
||||
else:
|
||||
ratio = weights[slug] / unlocked_weight_total
|
||||
amount = (remaining_pool * ratio).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
assigned += amount
|
||||
result[slug] = max(Decimal("0.00"), amount)
|
||||
return result
|
||||
|
||||
def _reason_for(self, slug: str, rule: dict | None, desi_pct: Decimal) -> str:
|
||||
if slug in self.PRIORITY_TARGETS and rule is not None:
|
||||
return (
|
||||
f"Zielbereich {to_decimal(rule['min_pct'])} bis {to_decimal(rule['max_pct'])} % "
|
||||
f"vom Einkommen."
|
||||
)
|
||||
flo_pct = Decimal("100.00") - to_decimal(desi_pct)
|
||||
personal_labels = personal_account_names()
|
||||
return (
|
||||
"Restbetrag nach Sparen, Urlaub und Freizeit mit Split "
|
||||
f"{personal_labels['persoenlich-flo']} {flo_pct} % / "
|
||||
f"{personal_labels['persoenlich-desi']} {to_decimal(desi_pct)} %."
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models import MonthlyAllocation, MonthlyEntryValue, MonthlyIncome, to_decimal
|
||||
|
||||
|
||||
class ComparisonService:
|
||||
def month_delta(self, current_month, previous_month) -> dict:
|
||||
if previous_month is None:
|
||||
zero = Decimal("0.00")
|
||||
return {
|
||||
"income_delta": zero,
|
||||
"cost_delta": zero,
|
||||
"remainder_delta": zero,
|
||||
"allocation_delta": zero,
|
||||
}
|
||||
return {
|
||||
"income_delta": self._total_income(current_month) - self._total_income(previous_month),
|
||||
"cost_delta": self._total_costs(current_month) - self._total_costs(previous_month),
|
||||
"remainder_delta": self._remainder(current_month) - self._remainder(previous_month),
|
||||
"allocation_delta": self._total_allocations(current_month)
|
||||
- self._total_allocations(previous_month),
|
||||
}
|
||||
|
||||
def top_entry_changes(self, current_month, previous_month, limit: int = 6) -> list[dict]:
|
||||
if previous_month is None:
|
||||
return []
|
||||
previous_values = {
|
||||
item.entry_id: to_decimal(item.planned_amount) for item in previous_month.entry_values
|
||||
}
|
||||
changes = []
|
||||
for item in current_month.entry_values:
|
||||
previous_amount = previous_values.get(item.entry_id, Decimal("0.00"))
|
||||
current_amount = to_decimal(item.planned_amount)
|
||||
delta = current_amount - previous_amount
|
||||
if delta:
|
||||
changes.append(
|
||||
{
|
||||
"entry_name": item.entry.name,
|
||||
"category_name": item.entry.category.name,
|
||||
"delta": delta,
|
||||
"current_amount": current_amount,
|
||||
"previous_amount": previous_amount,
|
||||
}
|
||||
)
|
||||
changes.sort(key=lambda item: abs(item["delta"]), reverse=True)
|
||||
return changes[:limit]
|
||||
|
||||
def _total_income(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.amount) for item in month.incomes), Decimal("0.00"))
|
||||
|
||||
def _total_costs(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.planned_amount) for item in month.entry_values), Decimal("0.00"))
|
||||
|
||||
def _total_allocations(self, month) -> Decimal:
|
||||
return sum((to_decimal(item.amount) for item in month.allocations), Decimal("0.00"))
|
||||
|
||||
def _remainder(self, month) -> Decimal:
|
||||
return self._total_income(month) - self._total_costs(month)
|
||||
@@ -0,0 +1,429 @@
|
||||
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)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import InAppNotification, Month, NotificationPreference, User
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, month_service, push_service, threshold: float):
|
||||
self.month_service = month_service
|
||||
self.push_service = push_service
|
||||
self.threshold = threshold
|
||||
|
||||
def run_monthly_checks(self, today: date | None = None) -> int:
|
||||
today = today or date.today()
|
||||
current_month = self.month_service.ensure_month(today)
|
||||
next_month_date = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
next_month = Month.query.filter_by(
|
||||
label=f"{next_month_date.year:04d}-{next_month_date.month:02d}"
|
||||
).first()
|
||||
summary = self.month_service.compute_summary(current_month)
|
||||
count = 0
|
||||
for user in User.query.filter_by(is_active=True).all():
|
||||
pref = user.notification_preference or NotificationPreference(user_id=user.id)
|
||||
if pref.id is None:
|
||||
db.session.add(pref)
|
||||
db.session.flush()
|
||||
if pref.notify_month_end and next_month is None and today.day >= 25:
|
||||
self._notify(
|
||||
user,
|
||||
"month_end",
|
||||
"Folgemonat fehlt",
|
||||
"Der nächste Monat ist noch nicht vorbereitet. Öffne Saldo und prüfe die Planung.",
|
||||
"/months/",
|
||||
)
|
||||
count += 1
|
||||
if pref.notify_missing_distribution and summary.remainder != summary.allocation_total:
|
||||
self._notify(
|
||||
user,
|
||||
"missing_distribution",
|
||||
"Restverteilung unvollständig",
|
||||
"Die geplante Verteilung deckt den aktuellen Restbetrag noch nicht vollständig ab.",
|
||||
f"/planning/{current_month.label}",
|
||||
)
|
||||
count += 1
|
||||
if pref.notify_missing_values and abs(float(summary.deltas["income_delta"])) >= self.threshold:
|
||||
self._notify(
|
||||
user,
|
||||
"income_change",
|
||||
"Einkommen hat sich deutlich verändert",
|
||||
"Durch eine Einkommensänderung sollten die Vorschläge und Verteilungen geprüft werden.",
|
||||
f"/planning/{current_month.label}",
|
||||
)
|
||||
count += 1
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
def _notify(self, user, kind: str, title: str, body: str, action_url: str) -> None:
|
||||
notification = InAppNotification(
|
||||
user_id=user.id, type=kind, title=title, body=body, action_url=action_url
|
||||
)
|
||||
db.session.add(notification)
|
||||
self.push_service.send_to_user(user, title=title, body=body, url=action_url)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
from app.models import PushSubscription
|
||||
|
||||
|
||||
class PushService:
|
||||
def __init__(self, public_key: str, private_key: str, claims: dict):
|
||||
self.public_key = public_key
|
||||
self.private_key = private_key
|
||||
self.claims = claims
|
||||
|
||||
def send_to_user(self, user, title: str, body: str, url: str = "/") -> int:
|
||||
if not self.public_key or not self.private_key:
|
||||
return 0
|
||||
sent = 0
|
||||
for sub in user.push_subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info={
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
|
||||
},
|
||||
data=f'{{"title":"{title}","body":"{body}","url":"{url}"}}',
|
||||
vapid_private_key=self.private_key,
|
||||
vapid_claims=self.claims,
|
||||
)
|
||||
sent += 1
|
||||
except WebPushException:
|
||||
continue
|
||||
return sent
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from app.models import CostParticipant, EntryShareRule, to_decimal
|
||||
|
||||
|
||||
class ShareCalculationService:
|
||||
def calculate_entry_shares(self, monthly_entry_value) -> dict:
|
||||
amount = to_decimal(monthly_entry_value.planned_amount)
|
||||
rules = monthly_entry_value.entry.share_rules
|
||||
if not rules:
|
||||
return {
|
||||
"total": amount,
|
||||
"internal_total": amount,
|
||||
"external_total": Decimal("0.00"),
|
||||
"shares": [],
|
||||
}
|
||||
|
||||
shares = []
|
||||
total_assigned = Decimal("0.00")
|
||||
equal_rules = [rule for rule in rules if rule.share_type == "equal"]
|
||||
non_equal_rules = [rule for rule in rules if rule.share_type != "equal"]
|
||||
|
||||
for rule in non_equal_rules:
|
||||
share_amount = self._amount_for_rule(amount, rule)
|
||||
total_assigned += share_amount
|
||||
shares.append(self._serialize_share(rule.participant, share_amount))
|
||||
|
||||
if equal_rules:
|
||||
remaining = amount - total_assigned
|
||||
unit = (remaining / Decimal(len(equal_rules))).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
for index, rule in enumerate(equal_rules):
|
||||
share_amount = unit
|
||||
if index == len(equal_rules) - 1:
|
||||
share_amount = amount - total_assigned - (unit * (len(equal_rules) - 1))
|
||||
shares.append(self._serialize_share(rule.participant, share_amount))
|
||||
|
||||
internal_total = sum(
|
||||
(item["amount"] for item in shares if not item["is_external"]), Decimal("0.00")
|
||||
)
|
||||
external_total = sum(
|
||||
(item["amount"] for item in shares if item["is_external"]), Decimal("0.00")
|
||||
)
|
||||
return {
|
||||
"total": amount,
|
||||
"internal_total": internal_total,
|
||||
"external_total": external_total,
|
||||
"shares": shares,
|
||||
}
|
||||
|
||||
def calculate_external_month_totals(self, month) -> list[dict]:
|
||||
participant_totals: dict[int, dict] = defaultdict(
|
||||
lambda: {
|
||||
"participant_id": None,
|
||||
"participant_name": "",
|
||||
"participant_avatar_url": None,
|
||||
"participant_avatar_initials": "",
|
||||
"total": Decimal("0.00"),
|
||||
"items": [],
|
||||
}
|
||||
)
|
||||
for value in month.entry_values:
|
||||
result = self.calculate_entry_shares(value)
|
||||
for share in result["shares"]:
|
||||
if not share["is_external"]:
|
||||
continue
|
||||
bucket = participant_totals[share["participant_id"]]
|
||||
bucket["participant_id"] = share["participant_id"]
|
||||
bucket["participant_name"] = share["participant_name"]
|
||||
bucket["participant_avatar_url"] = share["participant_avatar_url"]
|
||||
bucket["participant_avatar_initials"] = share["participant_avatar_initials"]
|
||||
bucket["total"] += share["amount"]
|
||||
bucket["items"].append({"entry_name": value.entry.name, "amount": share["amount"]})
|
||||
return sorted(participant_totals.values(), key=lambda item: item["participant_name"])
|
||||
|
||||
def _amount_for_rule(self, total_amount: Decimal, rule: EntryShareRule) -> Decimal:
|
||||
if rule.share_type == "percentage":
|
||||
return (total_amount * to_decimal(rule.share_value) / Decimal("100")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
if rule.share_type == "fixed":
|
||||
return to_decimal(rule.share_value)
|
||||
return Decimal("0.00")
|
||||
|
||||
def _serialize_share(self, participant: CostParticipant, amount: Decimal) -> dict:
|
||||
return {
|
||||
"participant_id": participant.id,
|
||||
"participant_name": participant.display_name,
|
||||
"participant_avatar_url": participant.avatar_url,
|
||||
"participant_avatar_initials": participant.avatar_initials,
|
||||
"amount": amount.quantize(Decimal("0.01")),
|
||||
"is_external": participant.is_external,
|
||||
}
|
||||
Reference in New Issue
Block a user