mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-11 21:54:18 +02:00
feat: add scriptable housekeeper rule engine
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user