mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add scriptable housekeeper rule engine
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
"Eine ribbonweite Mitgliederliste mit direktem Zugriff auf alle Akten ergänzt.",
|
||||
"Texthintergründe der Dashboard-Karten an die Kartenflächen angeglichen.",
|
||||
"Mitgliederlisten bleiben bei fehlerhaften Datumswerten bedienbar; der Hausmeister meldet die betroffene Akte zur Korrektur.",
|
||||
"Universelle, dateibasierte Hausmeister-Regeln mit atomarem Run-Journal, Task-Lifecycle und idempotenten Forderungsaktionen eingeführt.",
|
||||
"Regelskripte im Mitglieder-Store können eingebaute Regeln anhand ihres Dateinamens gezielt ersetzen.",
|
||||
"Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
|
||||
"Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.",
|
||||
"Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.",
|
||||
|
||||
@@ -137,6 +137,7 @@ class Event:
|
||||
class ContributionData:
|
||||
claims: list[dict[str, Any]] = field(default_factory=list)
|
||||
payments: list[dict[str, Any]] = field(default_factory=list)
|
||||
allocations: list[dict[str, Any]] = field(default_factory=list)
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -144,6 +145,7 @@ class ContributionData:
|
||||
"schema_version": self.schema_version,
|
||||
"claims": self.claims,
|
||||
"payments": self.payments,
|
||||
"allocations": self.allocations,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -152,6 +154,7 @@ class ContributionData:
|
||||
schema_version=int(data.get("schema_version", 1)),
|
||||
claims=list(data.get("claims") or []),
|
||||
payments=list(data.get("payments") or []),
|
||||
allocations=list(data.get("allocations") or []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Public API for CCMA housekeeper rule scripts."""
|
||||
|
||||
from ccma.rules.api import RuleAction, RuleContext
|
||||
|
||||
__all__ = ["RuleAction", "RuleContext"]
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from ccma.domain.models import ContributionData, Member
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuleContext:
|
||||
member: Member
|
||||
contributions: ContributionData
|
||||
today: date
|
||||
settings: Any
|
||||
repository_config: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuleAction:
|
||||
key: str
|
||||
action: Literal["task", "create_claim"]
|
||||
member_id: str
|
||||
payload: Mapping[str, Any]
|
||||
|
||||
|
||||
def task(
|
||||
*,
|
||||
rule_id: str,
|
||||
member: Member,
|
||||
key_suffix: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
detail: str,
|
||||
due_date: date | None = None,
|
||||
code: str | None = None,
|
||||
) -> RuleAction:
|
||||
payload: dict[str, Any] = {
|
||||
"severity": severity,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"code": code or rule_id,
|
||||
}
|
||||
if due_date:
|
||||
payload["due_date"] = due_date.isoformat()
|
||||
return RuleAction(
|
||||
key=f"{rule_id}:{member.member_id}:{key_suffix}",
|
||||
action="task",
|
||||
member_id=member.member_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def create_claim(
|
||||
*,
|
||||
rule_id: str,
|
||||
member: Member,
|
||||
claim_key: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> RuleAction:
|
||||
return RuleAction(
|
||||
key=f"{rule_id}:{member.member_id}:{claim_key}",
|
||||
action="create_claim",
|
||||
member_id=member.member_id,
|
||||
payload={"claim_key": claim_key, **payload},
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from ccma.rules.api import RuleAction, RuleContext
|
||||
|
||||
|
||||
class RuleLoadError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoadedRule:
|
||||
rule_id: str
|
||||
filename: str
|
||||
source: str
|
||||
path: Path
|
||||
script_hash: str
|
||||
evaluate: Callable[[RuleContext], list[RuleAction]]
|
||||
order: int
|
||||
|
||||
|
||||
def load_rules(store_root: Path) -> list[LoadedRule]:
|
||||
builtin_root = Path(__file__).resolve().parent / "scripts"
|
||||
override_root = store_root / "rules"
|
||||
selected = {path.name: (path, "builtin") for path in _script_files(builtin_root)}
|
||||
for path in _script_files(override_root):
|
||||
selected[path.name] = (path, "store-override" if path.name in selected else "store")
|
||||
rules = [_load_rule(path, source) for _name, (path, source) in sorted(selected.items())]
|
||||
return sorted(rules, key=lambda rule: (rule.order, rule.filename))
|
||||
|
||||
|
||||
def _script_files(root: Path) -> list[Path]:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return [path for path in root.glob("*.py") if not path.name.startswith("_")]
|
||||
|
||||
|
||||
def _load_rule(path: Path, source: str) -> LoadedRule:
|
||||
content = path.read_bytes()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
module_name = f"ccma_dynamic_rule_{path.stem}_{digest[:12]}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if not spec or not spec.loader:
|
||||
raise RuleLoadError(f"Regel {path.name} konnte nicht geladen werden.")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as exc:
|
||||
raise RuleLoadError(f"Regel {path.name} konnte nicht importiert werden: {exc}") from exc
|
||||
return _validate_module(module, path, source, digest)
|
||||
|
||||
|
||||
def _validate_module(module: ModuleType, path: Path, source: str, digest: str) -> LoadedRule:
|
||||
rule_id = str(getattr(module, "RULE_ID", "")).strip()
|
||||
evaluate = getattr(module, "evaluate", None)
|
||||
if not rule_id or not rule_id.replace("-", "").replace("_", "").isalnum():
|
||||
raise RuleLoadError(f"Regel {path.name} hat keine gültige RULE_ID.")
|
||||
if not callable(evaluate):
|
||||
raise RuleLoadError(f"Regel {path.name} definiert keine evaluate(context)-Funktion.")
|
||||
return LoadedRule(
|
||||
rule_id=rule_id,
|
||||
filename=path.name,
|
||||
source=source,
|
||||
path=path,
|
||||
script_hash=f"sha256:{digest}",
|
||||
evaluate=evaluate,
|
||||
order=int(getattr(module, "ORDER", 100)),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Built-in housekeeper rules. Store rules can override these files by name."""
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import date
|
||||
|
||||
CONTRIBUTION_STATUSES = {
|
||||
"accepted_pending_payment",
|
||||
"active",
|
||||
"suspended_contribution",
|
||||
"resigned_end_of_year",
|
||||
}
|
||||
|
||||
|
||||
def date_in_year(source: date, year: int) -> date:
|
||||
return date(year, source.month, min(source.day, calendar.monthrange(year, source.month)[1]))
|
||||
|
||||
|
||||
def relative_title(name: str, delta: int, occasion: str) -> str:
|
||||
if delta == 0:
|
||||
return f"{name} hat heute {occasion}"
|
||||
unit = "Tag" if abs(delta) == 1 else "Tagen"
|
||||
if delta > 0:
|
||||
return f"{name} hat in {delta} {unit} {occasion}"
|
||||
return f"{name} hatte vor {-delta} {unit} {occasion}"
|
||||
@@ -0,0 +1,41 @@
|
||||
from ccma.domain.dates import DateValidationError, validate_member_dates
|
||||
from ccma.rules.api import RuleContext, task
|
||||
|
||||
RULE_ID = "birthdate-check"
|
||||
ORDER = 10
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if not member.birth_date.strip():
|
||||
return [
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
key_suffix="missing",
|
||||
severity="warning",
|
||||
title=f"{member.display_name}: Geburtsdatum fehlt",
|
||||
detail="Das Geburtsdatum muss in der Mitgliederakte ergänzt werden.",
|
||||
code="missing_birth_date",
|
||||
)
|
||||
]
|
||||
try:
|
||||
validate_member_dates(
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
today=context.today,
|
||||
)
|
||||
except DateValidationError as exc:
|
||||
return [
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
key_suffix="invalid",
|
||||
severity="error",
|
||||
title=f"{member.display_name}: Ungültige Datumsangabe",
|
||||
detail=str(exc),
|
||||
code="invalid_member_dates",
|
||||
)
|
||||
]
|
||||
return []
|
||||
@@ -0,0 +1,36 @@
|
||||
from ccma.domain.dates import DateValidationError, validate_birth_date
|
||||
from ccma.rules.api import RuleContext, task
|
||||
from ccma.rules.scripts._shared import date_in_year, relative_title
|
||||
|
||||
RULE_ID = "birthday"
|
||||
ORDER = 20
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
try:
|
||||
birth_date = validate_birth_date(member.birth_date, today=context.today)
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not birth_date:
|
||||
return []
|
||||
occurrences = [
|
||||
date_in_year(birth_date, year) for year in range(context.today.year - 1, context.today.year + 2)
|
||||
]
|
||||
occurrence = min(occurrences, key=lambda value: abs((value - context.today).days))
|
||||
delta = (occurrence - context.today).days
|
||||
if delta > context.settings.birthday_days_before or delta < -context.settings.birthday_days_after:
|
||||
return []
|
||||
age = occurrence.year - birth_date.year
|
||||
detail = f"Wird {age} Jahre alt." if delta >= 0 else f"Ist {age} Jahre alt geworden."
|
||||
return [
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
key_suffix=str(occurrence.year),
|
||||
severity="info",
|
||||
title=relative_title(member.display_name, delta, "Geburtstag"),
|
||||
detail=detail,
|
||||
due_date=occurrence,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import date
|
||||
|
||||
from ccma.rules.api import RuleContext, task
|
||||
|
||||
RULE_ID = "claim-status"
|
||||
ORDER = 50
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
actions = []
|
||||
for claim in context.contributions.claims:
|
||||
if str(claim.get("status", "open")) not in {"open", "partially_paid"}:
|
||||
continue
|
||||
try:
|
||||
due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
delta = (due - context.today).days
|
||||
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
|
||||
title = str(claim.get("title") or "Beitragsforderung")
|
||||
claim_type = str(claim.get("type", ""))
|
||||
if delta < 0:
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"overdue:{claim_key}",
|
||||
severity="error",
|
||||
title=f"{context.member.display_name}: {title} überfällig",
|
||||
detail=f"Fälligkeit war vor {-delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
||||
)
|
||||
)
|
||||
elif delta <= 14:
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"due-soon:{claim_key}",
|
||||
severity="info",
|
||||
title=f"{context.member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||
)
|
||||
)
|
||||
return actions
|
||||
@@ -0,0 +1,152 @@
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from ccma.domain.dates import DateValidationError, parse_iso_date
|
||||
from ccma.rules.api import RuleContext, create_claim
|
||||
from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
||||
|
||||
RULE_ID = "contribution-claims"
|
||||
ORDER = 40
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.honorary or member.status not in CONTRIBUTION_STATUSES:
|
||||
return []
|
||||
try:
|
||||
accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss")
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not accepted_at or not started_at:
|
||||
return []
|
||||
|
||||
actions = []
|
||||
admission_rule = _rule_for(context.repository_config, accepted_at)
|
||||
if admission_rule:
|
||||
admission_fee = Decimal(str(admission_rule.get("admission_fee", "0")))
|
||||
if admission_fee > 0:
|
||||
due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28))
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
claim_key="admission-fee",
|
||||
payload={
|
||||
"type": "admission_fee",
|
||||
"title": "Aufnahmegebühr",
|
||||
"amount": _money(admission_fee),
|
||||
"due_date": (accepted_at + timedelta(days=due_days)).isoformat(),
|
||||
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for year in (context.today.year, context.today.year + 1):
|
||||
actions.extend(_membership_claims(context, started_at, accepted_at, year))
|
||||
return actions
|
||||
|
||||
|
||||
def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int):
|
||||
member = context.member
|
||||
period_start = max(started_at, date(year, 1, 1))
|
||||
if period_start.year > year:
|
||||
return []
|
||||
rule = _rule_for(context.repository_config, period_start)
|
||||
if not rule:
|
||||
return []
|
||||
annual_amount = Decimal(str(rule.get("annual_amount", "0")))
|
||||
issue_days = int(rule.get("issue_days_before_due", 30))
|
||||
due_days_after_entry = int(rule.get("first_payment_due_days_after_acceptance", 28))
|
||||
if annual_amount <= 0:
|
||||
return []
|
||||
|
||||
if member.payment_frequency == "semiannual":
|
||||
configured_due_dates = list(rule.get("semiannual_due") or ["01-31", "07-31"])
|
||||
while len(configured_due_dates) < 2:
|
||||
configured_due_dates.append(("01-31", "07-31")[len(configured_due_dates)])
|
||||
periods = [
|
||||
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
|
||||
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
|
||||
]
|
||||
else:
|
||||
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
||||
|
||||
actions = []
|
||||
monthly_amount = annual_amount / Decimal(12)
|
||||
for suffix, first_month, last_month, regular_due in periods:
|
||||
charged_from = max(first_month, period_start.month)
|
||||
months = max(0, last_month - charged_from + 1)
|
||||
if months == 0:
|
||||
continue
|
||||
amount = (monthly_amount * months).quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
entry_year = started_at.year == year
|
||||
issue_date = regular_due - timedelta(days=issue_days)
|
||||
if not entry_year and context.today < issue_date:
|
||||
continue
|
||||
due_date = regular_due
|
||||
if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry):
|
||||
due_date = accepted_at + timedelta(days=due_days_after_entry)
|
||||
claim_key = f"membership-fee:{year}:{suffix}"
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
claim_key=claim_key,
|
||||
payload={
|
||||
"type": "membership_fee",
|
||||
"title": _title(year, suffix),
|
||||
"amount": _money(amount),
|
||||
"due_date": due_date.isoformat(),
|
||||
"service_period": {
|
||||
"from": date(year, charged_from, 1).isoformat(),
|
||||
"until": date(year, last_month, calendar.monthrange(year, last_month)[1]).isoformat(),
|
||||
},
|
||||
"calculation": {
|
||||
"rule_id": rule.get("rule_id", ""),
|
||||
"annual_amount": _money(annual_amount),
|
||||
"months": months,
|
||||
"formula": "annual_amount * months / 12",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def _rule_for(config, target: date):
|
||||
selected = None
|
||||
for rule in config.get("contribution_rules", []):
|
||||
try:
|
||||
valid_from = date.fromisoformat(str(rule.get("valid_from", "")))
|
||||
valid_until_raw = rule.get("valid_until")
|
||||
valid_until = date.fromisoformat(str(valid_until_raw)) if valid_until_raw else None
|
||||
except ValueError:
|
||||
continue
|
||||
if valid_from <= target and (not valid_until or target <= valid_until):
|
||||
if selected is None or valid_from > selected[0]:
|
||||
selected = (valid_from, rule)
|
||||
return selected[1] if selected else None
|
||||
|
||||
|
||||
def _money(value: Decimal) -> str:
|
||||
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _due_date(year: int, value, fallback: str) -> date:
|
||||
try:
|
||||
month, day = (int(part) for part in str(value or fallback).split("-", 1))
|
||||
return date(year, month, day)
|
||||
except (TypeError, ValueError):
|
||||
month, day = (int(part) for part in fallback.split("-", 1))
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def _title(year: int, suffix: str) -> str:
|
||||
if suffix == "first-half":
|
||||
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
|
||||
if suffix == "second-half":
|
||||
return f"Mitgliedsbeitrag 2. Halbjahr {year}"
|
||||
return f"Mitgliedsbeitrag {year}"
|
||||
@@ -0,0 +1,50 @@
|
||||
from ccma.domain.dates import DateValidationError, parse_iso_date
|
||||
from ccma.rules.api import RuleContext, task
|
||||
from ccma.rules.scripts._shared import relative_title
|
||||
|
||||
RULE_ID = "membership-anniversary"
|
||||
ORDER = 30
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
try:
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not started_at:
|
||||
return []
|
||||
actions = []
|
||||
for interval in context.settings.anniversary_intervals:
|
||||
try:
|
||||
target = interval.target_date(started_at)
|
||||
except (OverflowError, ValueError):
|
||||
continue
|
||||
delta = (target - context.today).days
|
||||
if (
|
||||
delta > context.settings.anniversary_days_before
|
||||
or delta < -context.settings.anniversary_days_after
|
||||
):
|
||||
continue
|
||||
occasion = _anniversary_name(interval)
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
key_suffix=f"{interval.value}{interval.unit}",
|
||||
severity="info",
|
||||
title=relative_title(member.display_name, delta, occasion),
|
||||
detail=f"Mitglied seit {started_at:%d.%m.%Y}.",
|
||||
due_date=target,
|
||||
code="membership_anniversary",
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def _anniversary_name(interval):
|
||||
if interval.unit == "D":
|
||||
return f"{interval.value}-Tage-Mitgliedsjubiläum"
|
||||
if interval.unit == "M":
|
||||
return f"{interval.value}-Monats-Mitgliedsjubiläum"
|
||||
return f"{interval.value}-jähriges Mitgliedsjubiläum"
|
||||
+291
-205
@@ -1,18 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.dates import (
|
||||
DateValidationError,
|
||||
parse_iso_date,
|
||||
validate_birth_date,
|
||||
validate_member_dates,
|
||||
)
|
||||
from ccma.domain.models import HousekeeperFinding
|
||||
from ccma.rules.api import RuleAction, RuleContext
|
||||
from ccma.rules.loader import LoadedRule, load_rules
|
||||
from ccma.services.intervals import AnniversaryInterval, parse_anniversary_intervals
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.storage.atomic import read_json, write_json_atomic
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -48,212 +51,295 @@ class Housekeeper:
|
||||
def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None):
|
||||
self.repository = repository
|
||||
self.settings = settings or HousekeeperSettings()
|
||||
self.state_path = repository.root / "hausmeister.json"
|
||||
self.lock_path = repository.root / ".hausmeister.lock"
|
||||
|
||||
def run(self, today: date | None = None) -> list[HousekeeperFinding]:
|
||||
current_date = today or date.today()
|
||||
findings: list[HousekeeperFinding] = []
|
||||
for member in self.repository.list_members():
|
||||
try:
|
||||
validate_member_dates(
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
today=current_date,
|
||||
)
|
||||
except DateValidationError as exc:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="error",
|
||||
member_id=member.member_id,
|
||||
code="invalid_member_dates",
|
||||
title=f"{member.display_name}: Ungültige Datumsangabe",
|
||||
detail=str(exc),
|
||||
)
|
||||
)
|
||||
if member.status in {
|
||||
"active",
|
||||
"suspended_contribution",
|
||||
"resigned_end_of_year",
|
||||
"honorary",
|
||||
}:
|
||||
birthday = self._birthday_finding(
|
||||
member.member_id, member.display_name, member.birth_date, current_date
|
||||
)
|
||||
if birthday:
|
||||
findings.append(birthday)
|
||||
findings.extend(
|
||||
self._anniversary_findings(
|
||||
member.member_id,
|
||||
member.display_name,
|
||||
member.membership_started_at,
|
||||
current_date,
|
||||
)
|
||||
)
|
||||
if not member.contribution_rule_id and not member.honorary:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="error",
|
||||
member_id=member.member_id,
|
||||
code="missing_contribution_rule",
|
||||
title=f"{member.display_name}: Beitragsregel fehlt",
|
||||
detail="Dem Mitglied ist keine Beitragsregel zugeordnet.",
|
||||
)
|
||||
)
|
||||
with _exclusive_lock(self.lock_path):
|
||||
original = self._load_state()
|
||||
working = copy.deepcopy(original)
|
||||
counter = int(original.get("run_counter", 0)) + 1
|
||||
run_id = f"{current_date.isoformat()}:{counter:06d}"
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
items = _items_by_key(working)
|
||||
successful_scopes: set[tuple[str, str]] = set()
|
||||
|
||||
if member.status == "accepted_pending_payment" and member.accepted_at:
|
||||
accepted = _parse_date(member.accepted_at)
|
||||
if accepted:
|
||||
deadline = accepted + timedelta(days=28)
|
||||
days = (deadline - current_date).days
|
||||
if days < 0:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="error",
|
||||
rules = load_rules(self.repository.root)
|
||||
repository_config = self.repository.get_configuration()
|
||||
for member in self.repository.list_members():
|
||||
for rule in rules:
|
||||
scope = (rule.rule_id, member.member_id)
|
||||
try:
|
||||
context = RuleContext(
|
||||
member=member,
|
||||
contributions=self.repository.get_contributions(member.member_id),
|
||||
today=current_date,
|
||||
settings=self.settings,
|
||||
repository_config=repository_config,
|
||||
)
|
||||
actions = rule.evaluate(context)
|
||||
if not isinstance(actions, list):
|
||||
raise TypeError("evaluate(context) muss eine Liste zurückgeben")
|
||||
for action in actions:
|
||||
self._apply_action(items, action, rule, run_id, now)
|
||||
except Exception as exc:
|
||||
self._refresh_task(
|
||||
items,
|
||||
RuleAction(
|
||||
key=f"{rule.rule_id}:{member.member_id}:rule-error",
|
||||
action="task",
|
||||
member_id=member.member_id,
|
||||
code="initial_payment_overdue",
|
||||
title=f"{member.display_name}: Erstzahlung überfällig",
|
||||
detail=f"Die Vierwochenfrist ist seit {-days} Tagen überschritten.",
|
||||
due_date=deadline,
|
||||
)
|
||||
)
|
||||
elif days <= 7:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="warning",
|
||||
member_id=member.member_id,
|
||||
code="initial_payment_due_soon",
|
||||
title=f"{member.display_name}: Erstzahlung bald fällig",
|
||||
detail=f"Die Vierwochenfrist endet in {days} Tagen.",
|
||||
due_date=deadline,
|
||||
)
|
||||
payload={
|
||||
"severity": "error",
|
||||
"title": f"{member.display_name}: Regel fehlgeschlagen",
|
||||
"detail": f"{rule.filename}: {exc}",
|
||||
},
|
||||
),
|
||||
rule,
|
||||
run_id,
|
||||
now,
|
||||
)
|
||||
else:
|
||||
successful_scopes.add(scope)
|
||||
|
||||
contributions = self.repository.get_contributions(member.member_id)
|
||||
for claim in contributions.claims:
|
||||
if str(claim.get("status", "open")) not in {"open", "partially_paid"}:
|
||||
continue
|
||||
due = _parse_date(str(claim.get("due_date", "")))
|
||||
if not due:
|
||||
continue
|
||||
days = (due - current_date).days
|
||||
title = str(claim.get("title") or "Beitragsforderung")
|
||||
if days < 0:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="error",
|
||||
member_id=member.member_id,
|
||||
code="claim_overdue",
|
||||
title=f"{member.display_name}: {title} überfällig",
|
||||
detail=f"Fälligkeit war vor {-days} Tagen.",
|
||||
due_date=due,
|
||||
)
|
||||
)
|
||||
elif days <= 14:
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="info",
|
||||
member_id=member.member_id,
|
||||
code="claim_due_soon",
|
||||
title=f"{member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {days} Tagen.",
|
||||
due_date=due,
|
||||
)
|
||||
)
|
||||
severity_order = {"error": 0, "warning": 1, "info": 2}
|
||||
return sorted(
|
||||
findings, key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max)
|
||||
)
|
||||
|
||||
def _birthday_finding(
|
||||
self,
|
||||
member_id: str,
|
||||
name: str,
|
||||
birth_date_value: str,
|
||||
today: date,
|
||||
) -> HousekeeperFinding | None:
|
||||
try:
|
||||
birth_date = validate_birth_date(birth_date_value, today=today)
|
||||
except DateValidationError:
|
||||
return None
|
||||
if not birth_date:
|
||||
return None
|
||||
occurrences = [_birthday_in_year(birth_date, year) for year in range(today.year - 1, today.year + 2)]
|
||||
occurrence = min(occurrences, key=lambda value: abs((value - today).days))
|
||||
delta = (occurrence - today).days
|
||||
if delta > self.settings.birthday_days_before or delta < -self.settings.birthday_days_after:
|
||||
return None
|
||||
age = occurrence.year - birth_date.year
|
||||
title = _relative_title(name, delta, "Geburtstag")
|
||||
detail = f"Wird {age} Jahre alt." if delta >= 0 else f"Ist {age} Jahre alt geworden."
|
||||
return HousekeeperFinding(
|
||||
severity="info",
|
||||
member_id=member_id,
|
||||
code="birthday",
|
||||
title=title,
|
||||
detail=detail,
|
||||
due_date=occurrence,
|
||||
)
|
||||
|
||||
def _anniversary_findings(
|
||||
self,
|
||||
member_id: str,
|
||||
name: str,
|
||||
started_at_value: str,
|
||||
today: date,
|
||||
) -> list[HousekeeperFinding]:
|
||||
try:
|
||||
started_at = parse_iso_date(started_at_value, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not started_at:
|
||||
return []
|
||||
findings: list[HousekeeperFinding] = []
|
||||
for interval in self.settings.anniversary_intervals:
|
||||
try:
|
||||
target = interval.target_date(started_at)
|
||||
except (OverflowError, ValueError):
|
||||
continue
|
||||
delta = (target - today).days
|
||||
if delta > self.settings.anniversary_days_before or delta < -self.settings.anniversary_days_after:
|
||||
continue
|
||||
occasion = _anniversary_name(interval)
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity="info",
|
||||
member_id=member_id,
|
||||
code="membership_anniversary",
|
||||
title=_relative_title(name, delta, occasion),
|
||||
detail=f"Mitglied seit {started_at:%d.%m.%Y}.",
|
||||
due_date=target,
|
||||
)
|
||||
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
|
||||
working.update(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_counter": counter,
|
||||
"last_completed_run": run_id,
|
||||
"last_completed_at": now,
|
||||
"rules": [_rule_record(rule) for rule in rules],
|
||||
"items": sorted(items.values(), key=lambda item: str(item.get("key", ""))),
|
||||
}
|
||||
)
|
||||
return findings
|
||||
write_json_atomic(self.state_path, working)
|
||||
return _open_findings(working["items"])
|
||||
|
||||
def _apply_action(
|
||||
self,
|
||||
items: dict[str, dict[str, Any]],
|
||||
action: RuleAction,
|
||||
rule: LoadedRule,
|
||||
run_id: str,
|
||||
now: str,
|
||||
) -> None:
|
||||
self._validate_action(action, rule)
|
||||
if action.action == "task":
|
||||
self._refresh_task(items, action, rule, run_id, now)
|
||||
return
|
||||
if action.action == "create_claim":
|
||||
self._create_claim(items, action, rule, run_id, now)
|
||||
return
|
||||
raise ValueError(f"Unbekannte Aktion: {action.action}")
|
||||
|
||||
def _create_claim(
|
||||
self,
|
||||
items: dict[str, dict[str, Any]],
|
||||
action: RuleAction,
|
||||
rule: LoadedRule,
|
||||
run_id: str,
|
||||
now: str,
|
||||
) -> None:
|
||||
claim_key = str(action.payload.get("claim_key", "")).strip()
|
||||
if not claim_key:
|
||||
raise ValueError("create_claim benötigt claim_key")
|
||||
data = self.repository.get_contributions(action.member_id)
|
||||
existing = next(
|
||||
(claim for claim in data.claims if str(claim.get("claim_key", "")) == claim_key),
|
||||
None,
|
||||
)
|
||||
if existing is None:
|
||||
claim = {
|
||||
**dict(action.payload),
|
||||
"claim_id": str(uuid4()),
|
||||
"claim_key": claim_key,
|
||||
"status": "open",
|
||||
"created_at": now,
|
||||
"rule": {
|
||||
"id": rule.rule_id,
|
||||
"filename": rule.filename,
|
||||
"source": rule.source,
|
||||
"script_hash": rule.script_hash,
|
||||
},
|
||||
}
|
||||
data.claims.append(claim)
|
||||
self.repository.save_contributions(action.member_id, data)
|
||||
self.repository.append_event(
|
||||
action.member_id,
|
||||
event_type="claim_created",
|
||||
summary=f"Forderung automatisch angelegt: {claim.get('title', claim_key)}",
|
||||
references={"claim_id": str(claim["claim_id"]), "claim_key": claim_key},
|
||||
data={"amount": claim.get("amount", ""), "due_date": claim.get("due_date", "")},
|
||||
)
|
||||
target_id = claim["claim_id"]
|
||||
else:
|
||||
target_id = existing.get("claim_id", "")
|
||||
item = items.get(action.key, {})
|
||||
item.update(
|
||||
{
|
||||
"key": action.key,
|
||||
"rule_id": rule.rule_id,
|
||||
"rule_file": rule.filename,
|
||||
"rule_source": rule.source,
|
||||
"member_id": action.member_id,
|
||||
"action": "create_claim",
|
||||
"status": "applied",
|
||||
"first_seen_run": item.get("first_seen_run", run_id),
|
||||
"last_seen_run": run_id,
|
||||
"applied_run": item.get("applied_run", run_id),
|
||||
"applied_at": item.get("applied_at", now),
|
||||
"target": {"claim_id": target_id, "claim_key": claim_key},
|
||||
}
|
||||
)
|
||||
items[action.key] = item
|
||||
|
||||
@staticmethod
|
||||
def _refresh_task(
|
||||
items: dict[str, dict[str, Any]],
|
||||
action: RuleAction,
|
||||
rule: LoadedRule,
|
||||
run_id: str,
|
||||
now: str,
|
||||
) -> None:
|
||||
item = items.get(action.key, {})
|
||||
was_resolved = item.get("status") == "resolved"
|
||||
payload = dict(action.payload)
|
||||
item.update(
|
||||
{
|
||||
"key": action.key,
|
||||
"rule_id": rule.rule_id,
|
||||
"rule_file": rule.filename,
|
||||
"rule_source": rule.source,
|
||||
"member_id": action.member_id,
|
||||
"action": "task",
|
||||
"status": "open",
|
||||
"severity": str(payload.get("severity", "info")),
|
||||
"code": str(payload.get("code", rule.rule_id)),
|
||||
"title": str(payload.get("title", action.key)),
|
||||
"detail": str(payload.get("detail", "")),
|
||||
"due_date": payload.get("due_date"),
|
||||
"first_seen_run": item.get("first_seen_run", run_id),
|
||||
"first_seen_at": item.get("first_seen_at", now),
|
||||
"last_seen_run": run_id,
|
||||
"last_seen_at": now,
|
||||
"seen_count": int(item.get("seen_count", 0)) + 1,
|
||||
"reopened_count": int(item.get("reopened_count", 0)) + (1 if was_resolved else 0),
|
||||
"resolved_run": None,
|
||||
"resolved_at": None,
|
||||
}
|
||||
)
|
||||
items[action.key] = item
|
||||
|
||||
@staticmethod
|
||||
def _validate_action(action: RuleAction, rule: LoadedRule) -> None:
|
||||
if not isinstance(action, RuleAction):
|
||||
raise TypeError("Regeln dürfen nur RuleAction-Objekte zurückgeben")
|
||||
prefix = f"{rule.rule_id}:{action.member_id}:"
|
||||
if not action.key.startswith(prefix):
|
||||
raise ValueError(f"Aktions-Key muss mit {prefix} beginnen")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_stale_tasks(
|
||||
items: dict[str, dict[str, Any]],
|
||||
successful_scopes: set[tuple[str, str]],
|
||||
run_id: str,
|
||||
now: str,
|
||||
) -> None:
|
||||
for item in items.values():
|
||||
if item.get("action") != "task" or item.get("status") != "open":
|
||||
continue
|
||||
scope = (str(item.get("rule_id", "")), str(item.get("member_id", "")))
|
||||
if scope not in successful_scopes or item.get("last_seen_run") == run_id:
|
||||
continue
|
||||
item["status"] = "resolved"
|
||||
item["resolved_run"] = run_id
|
||||
item["resolved_at"] = now
|
||||
|
||||
def _load_state(self) -> dict[str, Any]:
|
||||
if not self.state_path.exists():
|
||||
return {"schema_version": 1, "run_counter": 0, "items": []}
|
||||
try:
|
||||
state = read_json(self.state_path)
|
||||
if not isinstance(state, dict) or not isinstance(state.get("items", []), list):
|
||||
raise ValueError("ungültige Struktur")
|
||||
return state
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RepositoryError(f"hausmeister.json konnte nicht gelesen werden: {exc}") from exc
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
def _items_by_key(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
items: dict[str, dict[str, Any]] = {}
|
||||
for raw in state.get("items", []):
|
||||
if isinstance(raw, dict) and raw.get("key"):
|
||||
items[str(raw["key"])] = dict(raw)
|
||||
return items
|
||||
|
||||
|
||||
def _rule_record(rule: LoadedRule) -> dict[str, str]:
|
||||
return {
|
||||
"rule_id": rule.rule_id,
|
||||
"filename": rule.filename,
|
||||
"source": rule.source,
|
||||
"script_hash": rule.script_hash,
|
||||
}
|
||||
|
||||
|
||||
def _open_findings(items: list[dict[str, Any]]) -> list[HousekeeperFinding]:
|
||||
findings: list[HousekeeperFinding] = []
|
||||
for item in items:
|
||||
if item.get("action") != "task" or item.get("status") != "open":
|
||||
continue
|
||||
due_date = None
|
||||
try:
|
||||
if item.get("due_date"):
|
||||
due_date = date.fromisoformat(str(item["due_date"]))
|
||||
except ValueError:
|
||||
pass
|
||||
findings.append(
|
||||
HousekeeperFinding(
|
||||
severity=str(item.get("severity", "info")),
|
||||
member_id=str(item.get("member_id", "")),
|
||||
code=str(item.get("code", item.get("rule_id", "housekeeper"))),
|
||||
title=str(item.get("title", item.get("key", "Hausmeister"))),
|
||||
detail=str(item.get("detail", "")),
|
||||
due_date=due_date,
|
||||
)
|
||||
)
|
||||
severity_order = {"error": 0, "warning": 1, "info": 2}
|
||||
return sorted(
|
||||
findings,
|
||||
key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max, item.title),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_lock(path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor: int | None = None
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
os.write(descriptor, str(os.getpid()).encode("ascii"))
|
||||
break
|
||||
except FileExistsError:
|
||||
if _lock_owner_is_running(path):
|
||||
raise RepositoryError("Der Hausmeister läuft bereits.") from None
|
||||
path.unlink(missing_ok=True)
|
||||
if descriptor is None:
|
||||
raise RepositoryError("Hausmeister-Sperre konnte nicht angelegt werden.")
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
yield
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _birthday_in_year(birth_date: date, year: int) -> date:
|
||||
day = min(birth_date.day, calendar.monthrange(year, birth_date.month)[1])
|
||||
return date(year, birth_date.month, day)
|
||||
|
||||
|
||||
def _relative_title(name: str, delta: int, occasion: str) -> str:
|
||||
if delta == 0:
|
||||
return f"{name} hat heute {occasion}"
|
||||
days = "Tag" if abs(delta) == 1 else "Tagen"
|
||||
if delta > 0:
|
||||
return f"{name} hat in {delta} {days} {occasion}"
|
||||
return f"{name} hatte vor {-delta} {days} {occasion}"
|
||||
|
||||
|
||||
def _anniversary_name(interval: AnniversaryInterval) -> str:
|
||||
if interval.unit == "D":
|
||||
return f"{interval.value}-Tage-Mitgliedsjubiläum"
|
||||
if interval.unit == "M":
|
||||
return f"{interval.value}-Monats-Mitgliedsjubiläum"
|
||||
return f"{interval.value}-jähriges Mitgliedsjubiläum"
|
||||
def _lock_owner_is_running(path: Path) -> bool:
|
||||
try:
|
||||
pid = int(path.read_text(encoding="ascii").strip())
|
||||
os.kill(pid, 0)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -34,11 +34,14 @@ DEFAULT_CONFIGURATION = {
|
||||
"rule_id": "standard-2022",
|
||||
"name": "Regulärer Beitrag ab 2022",
|
||||
"valid_from": "2022-01-01",
|
||||
"valid_until": None,
|
||||
"annual_amount": "150.00",
|
||||
"admission_fee": "10.00",
|
||||
"annual_due": "01-31",
|
||||
"semiannual_due": ["01-31", "07-31"],
|
||||
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
||||
"first_payment_due_days_after_acceptance": 28,
|
||||
"issue_days_before_due": 30,
|
||||
"reminder_fee": "5.00",
|
||||
"failed_debit_fee": "5.00",
|
||||
}
|
||||
@@ -53,6 +56,7 @@ class MemberRepository:
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.members_root.mkdir(parents=True, exist_ok=True)
|
||||
(self.root / "rules").mkdir(parents=True, exist_ok=True)
|
||||
config_path = self.root / "repository.json"
|
||||
if not config_path.exists():
|
||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||
@@ -272,6 +276,15 @@ class MemberRepository:
|
||||
def member_count(self) -> int:
|
||||
return sum(1 for _ in self._member_directories())
|
||||
|
||||
def get_configuration(self) -> dict:
|
||||
try:
|
||||
configuration = read_json(self.root / "repository.json")
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise RepositoryError(f"repository.json konnte nicht gelesen werden: {exc}") from exc
|
||||
if not isinstance(configuration, dict):
|
||||
raise RepositoryError("repository.json enthält keine gültige Konfiguration.")
|
||||
return configuration
|
||||
|
||||
def get_member_number_policy(self) -> dict[str, str]:
|
||||
try:
|
||||
config = read_json(self.root / "repository.json")
|
||||
|
||||
@@ -208,7 +208,9 @@ class HousekeeperTab(ttk.Frame):
|
||||
row=0, column=0, sticky="w"
|
||||
)
|
||||
ttk.Label(
|
||||
header, text="Prüfend, keine Aktionen werden automatisch ausgeführt", style="Mono.TLabel"
|
||||
header,
|
||||
text="Regeln prüfen Daten und führen idempotente Aktionen aus",
|
||||
style="Mono.TLabel",
|
||||
).grid(row=1, column=0, sticky="w")
|
||||
ttk.Button(header, text="Neu prüfen", command=self.refresh).grid(
|
||||
row=0, column=1, rowspan=2, padx=(0, 8)
|
||||
|
||||
Reference in New Issue
Block a user