mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
Fix housekeeper false-positive overdue errors and ended-member birthday/anniversary noise
An overdue claim whose reminder had already been sent, with that reminder's own payment deadline still running, fell through to the plain "überfällig" error finding whenever reminder_due's escalation check returned None to signal "not yet time for the next Mahnstufe" -- that None was indistinguishable from "never handled at all" to the caller, so it looked neglected even though a reminder was already out. Give that case its own low-priority "reminder_awaiting_deadline" finding instead of silently falling back to the overdue-error path. Birthday and membership-anniversary findings also kept firing for members whose status is "ended", which doesn't make sense once the membership itself is over. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4ecdf87d05
commit
56a3f5f037
@@ -8,6 +8,8 @@ ORDER = 20
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
birth_date = validate_birth_date(member.birth_date, today=context.today)
|
||||
except DateValidationError:
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 30
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
|
||||
@@ -105,8 +105,14 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
return None
|
||||
level = int(next_level.get("level", 0))
|
||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
||||
if not trigger_date or context.today < trigger_date:
|
||||
if not trigger_date:
|
||||
return None
|
||||
if context.today < trigger_date:
|
||||
# A reminder for the previous level was already sent and its own payment
|
||||
# deadline hasn't expired yet -- this is being handled, not neglected, so it
|
||||
# must not show up as the same "error" severity as a plain unaddressed overdue
|
||||
# claim (that fallback is what the caller uses when this returns None).
|
||||
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
@@ -134,6 +140,28 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
)
|
||||
|
||||
|
||||
def _awaiting_deadline_action(
|
||||
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
previous_level = level - 1
|
||||
previous = sent_levels.get(previous_level) or {}
|
||||
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
|
||||
severity="info",
|
||||
code="reminder_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
|
||||
@@ -61,6 +61,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
assert "Erste Mahnung" in next_task.title
|
||||
|
||||
|
||||
def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A reminder was already sent and its own deadline hasn't expired yet -- this is
|
||||
# being handled, so it must not appear as an unaddressed "error"-severity overdue
|
||||
# claim, only as a low-priority note that the existing deadline is still running.
|
||||
assert not any(item.code == "claim_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Zahlungserinnerung" in awaiting.title
|
||||
|
||||
|
||||
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
@@ -236,6 +236,39 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
|
||||
assert "membership-fee:2027:annual" not in claim_keys
|
||||
|
||||
|
||||
def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21")
|
||||
active.status = "active"
|
||||
active.accepted_at = "2025-06-21"
|
||||
active.membership_started_at = "2025-06-21"
|
||||
repository.save_member(active)
|
||||
ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21")
|
||||
ended.status = "ended"
|
||||
ended.accepted_at = "2025-06-21"
|
||||
ended.membership_started_at = "2025-06-21"
|
||||
ended.membership_ended_at = "2025-12-31"
|
||||
repository.save_member(ended)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
birthday_days_before=0,
|
||||
birthday_days_after=0,
|
||||
anniversary_days_before=0,
|
||||
anniversary_days_after=0,
|
||||
anniversary_intervals="1Y",
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
codes_by_member = {}
|
||||
for item in findings:
|
||||
codes_by_member.setdefault(item.member_id, set()).add(item.code)
|
||||
|
||||
assert "birthday" in codes_by_member.get(active.member_id, set())
|
||||
assert "membership_anniversary" in codes_by_member.get(active.member_id, set())
|
||||
assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"})
|
||||
|
||||
|
||||
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
Reference in New Issue
Block a user