diff --git a/README.md b/README.md index 10c012e..e386c6c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ On first start, select or create the central member-store directory. The ```text member-store/ ├── repository.json +├── hausmeister.json +├── rules/ └── members/ └── / ├── member.json @@ -44,4 +46,19 @@ member-store/ └── files/ ``` +## Housekeeper rules + +The housekeeper runs every rule for every member. Built-in Python rules live in +`ccma/rules/scripts/`. A member store can add rules in its `rules/` directory. +If a store rule has the same filename as a built-in rule, the store version +replaces the built-in version. + +Store rules are trusted executable Python code. Only place reviewed rules from +trusted sources in this directory. Rules return structured `RuleAction` objects; +CCMA performs all file writes, duplicate checks, audit events, and atomic updates. + +`hausmeister.json` is written only after a complete run. Each refreshed task gets +the pending run ID. A failed run therefore cannot advance the stored counter or +silently resolve existing tasks. + Do not place a real member store inside the source repository. diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 4914c4f..f2b8bb5 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -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.", diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index bf1dd2f..87576f3 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -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 []), ) diff --git a/src/ccma/rules/__init__.py b/src/ccma/rules/__init__.py new file mode 100644 index 0000000..24e5ca1 --- /dev/null +++ b/src/ccma/rules/__init__.py @@ -0,0 +1,5 @@ +"""Public API for CCMA housekeeper rule scripts.""" + +from ccma.rules.api import RuleAction, RuleContext + +__all__ = ["RuleAction", "RuleContext"] diff --git a/src/ccma/rules/api.py b/src/ccma/rules/api.py new file mode 100644 index 0000000..e008ade --- /dev/null +++ b/src/ccma/rules/api.py @@ -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}, + ) diff --git a/src/ccma/rules/loader.py b/src/ccma/rules/loader.py new file mode 100644 index 0000000..4d11dc7 --- /dev/null +++ b/src/ccma/rules/loader.py @@ -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)), + ) diff --git a/src/ccma/rules/scripts/__init__.py b/src/ccma/rules/scripts/__init__.py new file mode 100644 index 0000000..886747e --- /dev/null +++ b/src/ccma/rules/scripts/__init__.py @@ -0,0 +1 @@ +"""Built-in housekeeper rules. Store rules can override these files by name.""" diff --git a/src/ccma/rules/scripts/_shared.py b/src/ccma/rules/scripts/_shared.py new file mode 100644 index 0000000..4350f3e --- /dev/null +++ b/src/ccma/rules/scripts/_shared.py @@ -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}" diff --git a/src/ccma/rules/scripts/birthdate_check.py b/src/ccma/rules/scripts/birthdate_check.py new file mode 100644 index 0000000..ab5b80b --- /dev/null +++ b/src/ccma/rules/scripts/birthdate_check.py @@ -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 [] diff --git a/src/ccma/rules/scripts/birthday.py b/src/ccma/rules/scripts/birthday.py new file mode 100644 index 0000000..a3d7a06 --- /dev/null +++ b/src/ccma/rules/scripts/birthday.py @@ -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, + ) + ] diff --git a/src/ccma/rules/scripts/claim_status.py b/src/ccma/rules/scripts/claim_status.py new file mode 100644 index 0000000..346b474 --- /dev/null +++ b/src/ccma/rules/scripts/claim_status.py @@ -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 diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py new file mode 100644 index 0000000..bc5b9c3 --- /dev/null +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -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}" diff --git a/src/ccma/rules/scripts/membership_anniversary.py b/src/ccma/rules/scripts/membership_anniversary.py new file mode 100644 index 0000000..abded48 --- /dev/null +++ b/src/ccma/rules/scripts/membership_anniversary.py @@ -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" diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index 46126bd..f03b589 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -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 diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index fb3b977..df2d92d 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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") diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index aeb91a8..321e5e9 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -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) diff --git a/tests/test_housekeeper.py b/tests/test_housekeeper.py index a5d300b..5dd6af8 100644 --- a/tests/test_housekeeper.py +++ b/tests/test_housekeeper.py @@ -9,7 +9,7 @@ from ccma.storage.repository import MemberRepository def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() - member = repository.create_member(first_name="Test", last_name="Person") + member = repository.create_member(first_name="Test", last_name="Person", birth_date="1990-01-01") member.status = "accepted_pending_payment" member.accepted_at = "2026-01-01" repository.save_member(member) diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 0000000..21f5b3a --- /dev/null +++ b/tests/test_rules.py @@ -0,0 +1,108 @@ +import json +from datetime import date + +import pytest + +from ccma.rules.loader import RuleLoadError +from ccma.services.housekeeper import Housekeeper +from ccma.storage.repository import MemberRepository + + +def test_store_rule_overrides_builtin_rule_with_same_filename(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Override", last_name="Test") + (repository.root / "rules" / "birthdate_check.py").write_text( + """ +from ccma.rules.api import RuleAction + +RULE_ID = "birthdate-check" + +def evaluate(context): + return [RuleAction( + key=f"birthdate-check:{context.member.member_id}:override", + action="task", + member_id=context.member.member_id, + payload={ + "code": "override_active", + "severity": "info", + "title": "Store-Override aktiv", + "detail": "Die eingebaute Regel wurde ersetzt.", + }, + )] +""".strip(), + encoding="utf-8", + ) + + findings = Housekeeper(repository).run(today=date(2026, 6, 21)) + state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + + assert any(finding.code == "override_active" for finding in findings) + rule = next(item for item in state["rules"] if item["filename"] == "birthdate_check.py") + assert rule["source"] == "store-override" + assert rule["script_hash"].startswith("sha256:") + assert member.member_id in {item["member_id"] for item in state["items"]} + + +def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Contribution", last_name="Test", birth_date="1990-01-01") + member.status = "active" + member.accepted_at = "2026-04-15" + member.membership_started_at = "2026-04-15" + member.payment_frequency = "semiannual" + repository.save_member(member) + housekeeper = Housekeeper(repository) + + housekeeper.run(today=date(2026, 4, 15)) + first_claims = repository.get_contributions(member.member_id).claims + housekeeper.run(today=date(2026, 4, 15)) + second_claims = repository.get_contributions(member.member_id).claims + state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + + assert {claim["claim_key"] for claim in first_claims} == { + "admission-fee", + "membership-fee:2026:first-half", + "membership-fee:2026:second-half", + } + assert len(second_claims) == len(first_claims) == 3 + amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims} + assert amounts["membership-fee:2026:first-half"] == "37.50" + assert amounts["membership-fee:2026:second-half"] == "75.00" + assert state["run_counter"] == 2 + assert state["last_completed_run"] == "2026-04-15:000002" + + +def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Missing", last_name="Birthday") + housekeeper = Housekeeper(repository) + + housekeeper.run(today=date(2026, 6, 21)) + member.birth_date = "1990-01-01" + repository.save_member(member) + housekeeper.run(today=date(2026, 6, 21)) + state = json.loads((repository.root / "hausmeister.json").read_text(encoding="utf-8")) + task = next(item for item in state["items"] if item["key"].endswith(":missing")) + + assert task["status"] == "resolved" + assert task["first_seen_run"] == "2026-06-21:000001" + assert task["resolved_run"] == "2026-06-21:000002" + + +def test_failed_run_does_not_advance_persisted_run_id(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + repository.create_member(first_name="Failed", last_name="Rule") + housekeeper = Housekeeper(repository) + housekeeper.run(today=date(2026, 6, 21)) + state_before = (repository.root / "hausmeister.json").read_bytes() + (repository.root / "rules" / "broken.py").write_text("this is not python !!!", encoding="utf-8") + + with pytest.raises(RuleLoadError): + housekeeper.run(today=date(2026, 6, 21)) + + assert (repository.root / "hausmeister.json").read_bytes() == state_before + assert not (repository.root / ".hausmeister.lock").exists()