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
+2
View File
@@ -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.",
+3
View File
@@ -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 []),
)
+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"
+291 -205
View File
@@ -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
+13
View File
@@ -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")
+3 -1
View File
@@ -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)