mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add staged reminder workflow
This commit is contained in:
+172
-19
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
from uuid import uuid4
|
||||
@@ -37,6 +37,29 @@ DEFAULT_CONFIGURATION = {
|
||||
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
|
||||
},
|
||||
"member_number_sequences": {},
|
||||
"reminder_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,
|
||||
},
|
||||
],
|
||||
},
|
||||
"contribution_rules": [
|
||||
{
|
||||
"rule_id": "standard-2022",
|
||||
@@ -392,14 +415,17 @@ class MemberRepository:
|
||||
)
|
||||
return allocation
|
||||
|
||||
def add_reminder(
|
||||
def create_reminder_draft(
|
||||
self,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
*,
|
||||
level: int,
|
||||
name: str,
|
||||
payment_deadline_days: int,
|
||||
detail: str = "",
|
||||
fee: str = "0",
|
||||
channel: str = "email",
|
||||
) -> dict:
|
||||
if level < 1:
|
||||
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
|
||||
@@ -409,41 +435,156 @@ class MemberRepository:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected_fee < 0:
|
||||
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
|
||||
if payment_deadline_days < 1 or payment_deadline_days > 365:
|
||||
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.")
|
||||
if _dunning_hold_is_active(claim):
|
||||
raise RepositoryError("Für diese Forderung ist eine Mahnsperre aktiv.")
|
||||
if claim_balance(data, claim) <= 0:
|
||||
raise RepositoryError("Die Forderung hat keinen offenen Betrag.")
|
||||
if any(
|
||||
str(item.get("claim_id", "")) == claim_id
|
||||
and int(item.get("level", 0)) == level
|
||||
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||
for item in data.reminders
|
||||
):
|
||||
raise RepositoryError(f"Mahnstufe {level} existiert bereits.")
|
||||
if level > 1 and not any(
|
||||
str(item.get("claim_id", "")) == claim_id
|
||||
and int(item.get("level", 0)) == level - 1
|
||||
and str(item.get("status", "")) == "sent"
|
||||
for item in data.reminders
|
||||
):
|
||||
raise RepositoryError(f"Mahnstufe {level - 1} wurde noch nicht versandt.")
|
||||
created_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
reminder = {
|
||||
"reminder_id": str(uuid4()),
|
||||
"reminder_key": f"{claim_id}:level-{level}",
|
||||
"claim_id": claim_id,
|
||||
"level": level,
|
||||
"name": name.strip() or f"Mahnung Stufe {level}",
|
||||
"status": "draft",
|
||||
"detail": detail.strip(),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"channel": channel.strip() or "email",
|
||||
"created_at": created_at,
|
||||
"generated_at": None,
|
||||
"sent_at": None,
|
||||
"payment_deadline_days": payment_deadline_days,
|
||||
"payment_deadline": None,
|
||||
"balance_snapshot": money_text(claim_balance(data, claim)),
|
||||
"fee": money_text(selected_fee),
|
||||
"fee_item_id": None,
|
||||
"document": {"path": "", "sha256": ""},
|
||||
}
|
||||
if selected_fee > 0:
|
||||
item = {
|
||||
"item_id": str(uuid4()),
|
||||
"type": "fee",
|
||||
"description": f"Mahngebühr Stufe {level}",
|
||||
"quantity": "1.00",
|
||||
"unit_price": money_text(selected_fee),
|
||||
"amount": money_text(selected_fee),
|
||||
"created_at": reminder["created_at"],
|
||||
}
|
||||
materialize_claim_items(claim).append(item)
|
||||
claim["amount"] = money_text(claim_total(claim))
|
||||
reminder["fee_item_id"] = item["item_id"]
|
||||
data.reminders.append(reminder)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="reminder_created",
|
||||
summary=f"Mahnung Stufe {level} erfasst",
|
||||
event_type="reminder_draft_created",
|
||||
summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
data={"fee": money_text(selected_fee)},
|
||||
data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]},
|
||||
)
|
||||
return reminder
|
||||
|
||||
def mark_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "")) not in {"draft", "generated"}:
|
||||
raise RepositoryError("Nur ein Entwurf kann als versandt markiert werden.")
|
||||
if claim_balance(data, claim) <= 0:
|
||||
raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.")
|
||||
now = datetime.now().astimezone()
|
||||
selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr")
|
||||
if selected_fee > 0 and not reminder.get("fee_item_id"):
|
||||
item = {
|
||||
"item_id": str(uuid4()),
|
||||
"type": "fee",
|
||||
"description": f"Mahngebühr Stufe {reminder.get('level', '')}",
|
||||
"quantity": "1.00",
|
||||
"unit_price": money_text(selected_fee),
|
||||
"amount": money_text(selected_fee),
|
||||
"created_at": now.isoformat(timespec="seconds"),
|
||||
}
|
||||
materialize_claim_items(claim).append(item)
|
||||
claim["amount"] = money_text(claim_total(claim))
|
||||
reminder["fee_item_id"] = item["item_id"]
|
||||
reminder["status"] = "sent"
|
||||
reminder["sent_at"] = now.isoformat(timespec="seconds")
|
||||
reminder["payment_deadline"] = (
|
||||
now.date() + timedelta(days=int(reminder.get("payment_deadline_days", 14)))
|
||||
).isoformat()
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="reminder_sent",
|
||||
summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}",
|
||||
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||
data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]},
|
||||
)
|
||||
return reminder
|
||||
|
||||
def cancel_reminder(self, member_id: str, claim_id: str, reminder_id: str) -> None:
|
||||
data, _claim = self.get_claim(member_id, claim_id)
|
||||
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "")) == "sent":
|
||||
raise RepositoryError("Eine bereits versandte Mahnung kann nicht verworfen werden.")
|
||||
reminder["status"] = "cancelled"
|
||||
reminder["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="reminder_cancelled",
|
||||
summary=f"Mahnungsentwurf verworfen: {reminder.get('name', '')}",
|
||||
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
||||
)
|
||||
|
||||
def set_dunning_hold(
|
||||
self,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
*,
|
||||
active: bool,
|
||||
reason: str = "",
|
||||
until: str = "",
|
||||
) -> None:
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
try:
|
||||
normalized_until = normalize_date_input(until, "Mahnsperre bis")
|
||||
except DateValidationError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
claim["dunning_hold"] = {
|
||||
"active": active,
|
||||
"reason": reason.strip(),
|
||||
"until": normalized_until or None,
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="dunning_hold_changed",
|
||||
summary="Mahnsperre gesetzt" if active else "Mahnsperre aufgehoben",
|
||||
references={"claim_id": claim_id},
|
||||
data={"reason": reason.strip(), "until": normalized_until},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_reminder(data: ContributionData, claim_id: str, reminder_id: str) -> dict:
|
||||
reminder = next(
|
||||
(
|
||||
item
|
||||
for item in data.reminders
|
||||
if str(item.get("claim_id", "")) == claim_id
|
||||
and str(item.get("reminder_id", "")) == reminder_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if reminder is None:
|
||||
raise RepositoryError("Mahnung nicht gefunden.")
|
||||
return reminder
|
||||
|
||||
def cancel_claim(self, member_id: str, claim_id: str) -> None:
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
if claim_balance(data, claim) != claim_total(claim):
|
||||
@@ -677,6 +818,18 @@ def _german_date(value: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _dunning_hold_is_active(claim: dict) -> bool:
|
||||
hold = claim.get("dunning_hold") or {}
|
||||
if not hold.get("active"):
|
||||
return False
|
||||
if not hold.get("until"):
|
||||
return True
|
||||
try:
|
||||
return date.fromisoformat(str(hold["until"])) >= date.today()
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def validate_member_number_pattern(pattern: str) -> None:
|
||||
selected = pattern.strip()
|
||||
if not selected:
|
||||
|
||||
Reference in New Issue
Block a user