feat: add scriptable housekeeper rule engine

This commit is contained in:
Marcel Peterkau
2026-06-21 17:43:04 +02:00
parent e63abbae81
commit 4bc1a8a200
18 changed files with 936 additions and 207 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Public API for CCMA housekeeper rule scripts."""
from ccma.rules.api import RuleAction, RuleContext
__all__ = ["RuleAction", "RuleContext"]
+67
View File
@@ -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},
)
+74
View File
@@ -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)),
)
+1
View File
@@ -0,0 +1 @@
"""Built-in housekeeper rules. Store rules can override these files by name."""
+24
View File
@@ -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}"
+41
View File
@@ -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 []
+36
View File
@@ -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,
)
]
+48
View File
@@ -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"