mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
feat: add staged reminder workflow
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||
from ccma.rules.api import RuleContext, task
|
||||
|
||||
RULE_ID = "reminder-due"
|
||||
ORDER = 60
|
||||
|
||||
DEFAULT_POLICY = {
|
||||
"grace_days_after_due": 7,
|
||||
"levels": [
|
||||
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14},
|
||||
{"level": 2, "name": "Erste Mahnung", "fee": "5.00", "payment_deadline_days": 14},
|
||||
{"level": 3, "name": "Zweite Mahnung", "fee": "5.00", "payment_deadline_days": 14},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||
actions = []
|
||||
for claim in context.contributions.claims:
|
||||
claim_id = str(claim.get("claim_id", ""))
|
||||
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
|
||||
"open",
|
||||
"partially_paid",
|
||||
"overdue",
|
||||
}:
|
||||
continue
|
||||
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
|
||||
continue
|
||||
reminders = [
|
||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||
]
|
||||
sent_levels = {
|
||||
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
|
||||
}
|
||||
next_level = next(
|
||||
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
|
||||
None,
|
||||
)
|
||||
if not next_level:
|
||||
continue
|
||||
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:
|
||||
continue
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
)
|
||||
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
title = (
|
||||
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
|
||||
if draft_exists
|
||||
else f"{context.member.display_name}: {name} fällig"
|
||||
)
|
||||
detail = (
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
|
||||
)
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{level}",
|
||||
severity="warning",
|
||||
code="reminder_due",
|
||||
title=title,
|
||||
detail=detail,
|
||||
due_date=trigger_date,
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
due_date = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
return None
|
||||
return due_date + timedelta(days=int(policy.get("grace_days_after_due", 7)))
|
||||
previous = sent_levels.get(level - 1)
|
||||
if not previous:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(previous.get("payment_deadline", "")))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _hold_is_active(claim, today: date) -> bool:
|
||||
hold = claim.get("dunning_hold") or {}
|
||||
if not hold.get("active"):
|
||||
return False
|
||||
until = hold.get("until")
|
||||
if not until:
|
||||
return True
|
||||
try:
|
||||
return date.fromisoformat(str(until)) >= today
|
||||
except ValueError:
|
||||
return True
|
||||
Reference in New Issue
Block a user