mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
feat: add scriptable housekeeper rule engine
This commit is contained in:
@@ -36,6 +36,8 @@ On first start, select or create the central member-store directory. The
|
|||||||
```text
|
```text
|
||||||
member-store/
|
member-store/
|
||||||
├── repository.json
|
├── repository.json
|
||||||
|
├── hausmeister.json
|
||||||
|
├── rules/
|
||||||
└── members/
|
└── members/
|
||||||
└── <uuid>/
|
└── <uuid>/
|
||||||
├── member.json
|
├── member.json
|
||||||
@@ -44,4 +46,19 @@ member-store/
|
|||||||
└── files/
|
└── 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.
|
Do not place a real member store inside the source repository.
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"Eine ribbonweite Mitgliederliste mit direktem Zugriff auf alle Akten ergänzt.",
|
"Eine ribbonweite Mitgliederliste mit direktem Zugriff auf alle Akten ergänzt.",
|
||||||
"Texthintergründe der Dashboard-Karten an die Kartenflächen angeglichen.",
|
"Texthintergründe der Dashboard-Karten an die Kartenflächen angeglichen.",
|
||||||
"Mitgliederlisten bleiben bei fehlerhaften Datumswerten bedienbar; der Hausmeister meldet die betroffene Akte zur Korrektur.",
|
"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.",
|
"Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
|
||||||
"Statusänderungen werden mit altem und neuem Klartextwert in der Mitgliederchronik protokolliert.",
|
"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.",
|
"Fensterposition, normaler Fensterzustand und Maximierung werden gespeichert; der Splash startet auf dem zuletzt verwendeten Monitor.",
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class Event:
|
|||||||
class ContributionData:
|
class ContributionData:
|
||||||
claims: list[dict[str, Any]] = field(default_factory=list)
|
claims: list[dict[str, Any]] = field(default_factory=list)
|
||||||
payments: 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
|
schema_version: int = 1
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
@@ -144,6 +145,7 @@ class ContributionData:
|
|||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"claims": self.claims,
|
"claims": self.claims,
|
||||||
"payments": self.payments,
|
"payments": self.payments,
|
||||||
|
"allocations": self.allocations,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -152,6 +154,7 @@ class ContributionData:
|
|||||||
schema_version=int(data.get("schema_version", 1)),
|
schema_version=int(data.get("schema_version", 1)),
|
||||||
claims=list(data.get("claims") or []),
|
claims=list(data.get("claims") or []),
|
||||||
payments=list(data.get("payments") 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"
|
||||||
+279
-193
@@ -1,18 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import calendar
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field
|
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.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.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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -48,212 +51,295 @@ class Housekeeper:
|
|||||||
def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None):
|
def __init__(self, repository: MemberRepository, settings: HousekeeperSettings | None = None):
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.settings = settings or HousekeeperSettings()
|
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]:
|
def run(self, today: date | None = None) -> list[HousekeeperFinding]:
|
||||||
current_date = today or date.today()
|
current_date = today or date.today()
|
||||||
findings: list[HousekeeperFinding] = []
|
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()
|
||||||
|
|
||||||
|
rules = load_rules(self.repository.root)
|
||||||
|
repository_config = self.repository.get_configuration()
|
||||||
for member in self.repository.list_members():
|
for member in self.repository.list_members():
|
||||||
|
for rule in rules:
|
||||||
|
scope = (rule.rule_id, member.member_id)
|
||||||
try:
|
try:
|
||||||
validate_member_dates(
|
context = RuleContext(
|
||||||
birth_date=member.birth_date,
|
member=member,
|
||||||
accepted_at=member.accepted_at,
|
contributions=self.repository.get_contributions(member.member_id),
|
||||||
membership_started_at=member.membership_started_at,
|
|
||||||
today=current_date,
|
today=current_date,
|
||||||
|
settings=self.settings,
|
||||||
|
repository_config=repository_config,
|
||||||
)
|
)
|
||||||
except DateValidationError as exc:
|
actions = rule.evaluate(context)
|
||||||
findings.append(
|
if not isinstance(actions, list):
|
||||||
HousekeeperFinding(
|
raise TypeError("evaluate(context) muss eine Liste zurückgeben")
|
||||||
severity="error",
|
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,
|
member_id=member.member_id,
|
||||||
code="invalid_member_dates",
|
payload={
|
||||||
title=f"{member.display_name}: Ungültige Datumsangabe",
|
"severity": "error",
|
||||||
detail=str(exc),
|
"title": f"{member.display_name}: Regel fehlgeschlagen",
|
||||||
)
|
"detail": f"{rule.filename}: {exc}",
|
||||||
)
|
},
|
||||||
if member.status in {
|
),
|
||||||
"active",
|
rule,
|
||||||
"suspended_contribution",
|
run_id,
|
||||||
"resigned_end_of_year",
|
now,
|
||||||
"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.",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
successful_scopes.add(scope)
|
||||||
|
|
||||||
if member.status == "accepted_pending_payment" and member.accepted_at:
|
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
|
||||||
accepted = _parse_date(member.accepted_at)
|
working.update(
|
||||||
if accepted:
|
{
|
||||||
deadline = accepted + timedelta(days=28)
|
"schema_version": 1,
|
||||||
days = (deadline - current_date).days
|
"run_counter": counter,
|
||||||
if days < 0:
|
"last_completed_run": run_id,
|
||||||
findings.append(
|
"last_completed_at": now,
|
||||||
HousekeeperFinding(
|
"rules": [_rule_record(rule) for rule in rules],
|
||||||
severity="error",
|
"items": sorted(items.values(), key=lambda item: str(item.get("key", ""))),
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
write_json_atomic(self.state_path, working)
|
||||||
|
return _open_findings(working["items"])
|
||||||
|
|
||||||
contributions = self.repository.get_contributions(member.member_id)
|
def _apply_action(
|
||||||
for claim in contributions.claims:
|
self,
|
||||||
if str(claim.get("status", "open")) not in {"open", "partially_paid"}:
|
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
|
continue
|
||||||
due = _parse_date(str(claim.get("due_date", "")))
|
scope = (str(item.get("rule_id", "")), str(item.get("member_id", "")))
|
||||||
if not due:
|
if scope not in successful_scopes or item.get("last_seen_run") == run_id:
|
||||||
continue
|
continue
|
||||||
days = (due - current_date).days
|
item["status"] = "resolved"
|
||||||
title = str(claim.get("title") or "Beitragsforderung")
|
item["resolved_run"] = run_id
|
||||||
if days < 0:
|
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 _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(
|
findings.append(
|
||||||
HousekeeperFinding(
|
HousekeeperFinding(
|
||||||
severity="error",
|
severity=str(item.get("severity", "info")),
|
||||||
member_id=member.member_id,
|
member_id=str(item.get("member_id", "")),
|
||||||
code="claim_overdue",
|
code=str(item.get("code", item.get("rule_id", "housekeeper"))),
|
||||||
title=f"{member.display_name}: {title} überfällig",
|
title=str(item.get("title", item.get("key", "Hausmeister"))),
|
||||||
detail=f"Fälligkeit war vor {-days} Tagen.",
|
detail=str(item.get("detail", "")),
|
||||||
due_date=due,
|
due_date=due_date,
|
||||||
)
|
|
||||||
)
|
|
||||||
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}
|
severity_order = {"error": 0, "warning": 1, "info": 2}
|
||||||
return sorted(
|
return sorted(
|
||||||
findings, key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max)
|
findings,
|
||||||
|
key=lambda item: (severity_order.get(item.severity, 9), item.due_date or date.max, item.title),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _birthday_finding(
|
|
||||||
self,
|
@contextmanager
|
||||||
member_id: str,
|
def _exclusive_lock(path: Path):
|
||||||
name: str,
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
birth_date_value: str,
|
descriptor: int | None = None
|
||||||
today: date,
|
for _attempt in range(2):
|
||||||
) -> HousekeeperFinding | None:
|
|
||||||
try:
|
try:
|
||||||
birth_date = validate_birth_date(birth_date_value, today=today)
|
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
except DateValidationError:
|
os.write(descriptor, str(os.getpid()).encode("ascii"))
|
||||||
return None
|
break
|
||||||
if not birth_date:
|
except FileExistsError:
|
||||||
return None
|
if _lock_owner_is_running(path):
|
||||||
occurrences = [_birthday_in_year(birth_date, year) for year in range(today.year - 1, today.year + 2)]
|
raise RepositoryError("Der Hausmeister läuft bereits.") from None
|
||||||
occurrence = min(occurrences, key=lambda value: abs((value - today).days))
|
path.unlink(missing_ok=True)
|
||||||
delta = (occurrence - today).days
|
if descriptor is None:
|
||||||
if delta > self.settings.birthday_days_before or delta < -self.settings.birthday_days_after:
|
raise RepositoryError("Hausmeister-Sperre konnte nicht angelegt werden.")
|
||||||
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:
|
try:
|
||||||
started_at = parse_iso_date(started_at_value, "Mitglied seit")
|
yield
|
||||||
except DateValidationError:
|
finally:
|
||||||
return []
|
os.close(descriptor)
|
||||||
if not started_at:
|
path.unlink(missing_ok=True)
|
||||||
return []
|
|
||||||
findings: list[HousekeeperFinding] = []
|
|
||||||
for interval in self.settings.anniversary_intervals:
|
def _lock_owner_is_running(path: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
target = interval.target_date(started_at)
|
pid = int(path.read_text(encoding="ascii").strip())
|
||||||
except (OverflowError, ValueError):
|
os.kill(pid, 0)
|
||||||
continue
|
except (OSError, ValueError):
|
||||||
delta = (target - today).days
|
return False
|
||||||
if delta > self.settings.anniversary_days_before or delta < -self.settings.anniversary_days_after:
|
return True
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: str) -> date | None:
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(value[:10])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ DEFAULT_CONFIGURATION = {
|
|||||||
"rule_id": "standard-2022",
|
"rule_id": "standard-2022",
|
||||||
"name": "Regulärer Beitrag ab 2022",
|
"name": "Regulärer Beitrag ab 2022",
|
||||||
"valid_from": "2022-01-01",
|
"valid_from": "2022-01-01",
|
||||||
|
"valid_until": None,
|
||||||
"annual_amount": "150.00",
|
"annual_amount": "150.00",
|
||||||
"admission_fee": "10.00",
|
"admission_fee": "10.00",
|
||||||
"annual_due": "01-31",
|
"annual_due": "01-31",
|
||||||
"semiannual_due": ["01-31", "07-31"],
|
"semiannual_due": ["01-31", "07-31"],
|
||||||
|
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
||||||
"first_payment_due_days_after_acceptance": 28,
|
"first_payment_due_days_after_acceptance": 28,
|
||||||
|
"issue_days_before_due": 30,
|
||||||
"reminder_fee": "5.00",
|
"reminder_fee": "5.00",
|
||||||
"failed_debit_fee": "5.00",
|
"failed_debit_fee": "5.00",
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,7 @@ class MemberRepository:
|
|||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
self.members_root.mkdir(parents=True, exist_ok=True)
|
self.members_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(self.root / "rules").mkdir(parents=True, exist_ok=True)
|
||||||
config_path = self.root / "repository.json"
|
config_path = self.root / "repository.json"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||||
@@ -272,6 +276,15 @@ class MemberRepository:
|
|||||||
def member_count(self) -> int:
|
def member_count(self) -> int:
|
||||||
return sum(1 for _ in self._member_directories())
|
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]:
|
def get_member_number_policy(self) -> dict[str, str]:
|
||||||
try:
|
try:
|
||||||
config = read_json(self.root / "repository.json")
|
config = read_json(self.root / "repository.json")
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ class HousekeeperTab(ttk.Frame):
|
|||||||
row=0, column=0, sticky="w"
|
row=0, column=0, sticky="w"
|
||||||
)
|
)
|
||||||
ttk.Label(
|
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")
|
).grid(row=1, column=0, sticky="w")
|
||||||
ttk.Button(header, text="Neu prüfen", command=self.refresh).grid(
|
ttk.Button(header, text="Neu prüfen", command=self.refresh).grid(
|
||||||
row=0, column=1, rowspan=2, padx=(0, 8)
|
row=0, column=1, rowspan=2, padx=(0, 8)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ccma.storage.repository import MemberRepository
|
|||||||
def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
|
def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
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.status = "accepted_pending_payment"
|
||||||
member.accepted_at = "2026-01-01"
|
member.accepted_at = "2026-01-01"
|
||||||
repository.save_member(member)
|
repository.save_member(member)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user