mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 19:26:53 +02:00
feat: add staged reminder workflow
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.",
|
||||
"Forderungen besitzen eigene Tabs mit Positionen, Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.",
|
||||
"Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.",
|
||||
"Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.",
|
||||
"Hausmeister um konfigurierbare Geburtstags- und Mitgliedsjubiläumsmeldungen erweitert.",
|
||||
"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.",
|
||||
|
||||
@@ -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
|
||||
+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:
|
||||
|
||||
+177
-20
@@ -19,7 +19,13 @@ from ccma.domain.contributions import (
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.labels import CLAIM_ITEM_TYPE_LABELS, display_label, storage_key
|
||||
from ccma.ui.labels import (
|
||||
CLAIM_ITEM_TYPE_LABELS,
|
||||
REMINDER_CHANNEL_LABELS,
|
||||
REMINDER_STATUS_LABELS,
|
||||
display_label,
|
||||
storage_key,
|
||||
)
|
||||
|
||||
|
||||
class ClaimTab(ttk.Frame):
|
||||
@@ -86,6 +92,8 @@ class ClaimTab(ttk.Frame):
|
||||
footer = ttk.Frame(self)
|
||||
footer.grid(row=3, column=0, sticky="ew", pady=(10, 0))
|
||||
footer.columnconfigure(0, weight=1)
|
||||
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
|
||||
self.hold_button.grid(row=0, column=0, sticky="w")
|
||||
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
||||
self.cancel_button.grid(row=0, column=1, sticky="e")
|
||||
|
||||
@@ -135,19 +143,34 @@ class ClaimTab(ttk.Frame):
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(0, weight=1)
|
||||
self.reminders = ttk.Treeview(parent, columns=("created", "level", "detail", "fee"), show="headings")
|
||||
self.reminders = ttk.Treeview(
|
||||
parent,
|
||||
columns=("created", "level", "name", "status", "deadline", "fee"),
|
||||
show="headings",
|
||||
)
|
||||
for key, title, width in (
|
||||
("created", "Erstellt", 150),
|
||||
("level", "Stufe", 80),
|
||||
("detail", "Details", 430),
|
||||
("fee", "Gebühr", 100),
|
||||
("level", "Stufe", 60),
|
||||
("name", "Mahnung", 230),
|
||||
("status", "Status", 130),
|
||||
("deadline", "Zahlungsfrist", 120),
|
||||
("fee", "Gebühr", 90),
|
||||
):
|
||||
self.reminders.heading(key, text=title)
|
||||
self.reminders.column(key, width=width, anchor="w")
|
||||
self.reminders.grid(row=0, column=0, sticky="nsew")
|
||||
ttk.Button(parent, text="Mahnung erfassen", command=self._add_reminder).grid(
|
||||
row=1, column=0, sticky="e", pady=(10, 0)
|
||||
self.reminders.bind("<<TreeviewSelect>>", lambda _event: self._update_reminder_buttons())
|
||||
buttons = ttk.Frame(parent)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(10, 0))
|
||||
self.discard_reminder_button = ttk.Button(
|
||||
buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled"
|
||||
)
|
||||
self.discard_reminder_button.pack(side="left", padx=(0, 8))
|
||||
self.send_reminder_button = ttk.Button(
|
||||
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
|
||||
)
|
||||
self.send_reminder_button.pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="left")
|
||||
|
||||
def refresh(self) -> None:
|
||||
try:
|
||||
@@ -165,11 +188,17 @@ class ClaimTab(ttk.Frame):
|
||||
self.subtitle_var.set(
|
||||
f"{member.member_number} · {member.display_name} · Fällig: {due or '—'} · {self.claim_id}"
|
||||
)
|
||||
hold = self.claim.get("dunning_hold") or {}
|
||||
if hold.get("active"):
|
||||
until = format_date_for_display(str(hold.get("until") or "")) or "unbefristet"
|
||||
reason = str(hold.get("reason") or "ohne Begründung")
|
||||
self.subtitle_var.set(f"{self.subtitle_var.get()} · MAHNSPERRE bis {until}: {reason}")
|
||||
self.summary_vars["total"].set(f"{money_text(total)} EUR")
|
||||
self.summary_vars["paid"].set(f"{money_text(paid)} EUR")
|
||||
self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
|
||||
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
|
||||
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
|
||||
self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
|
||||
self._render_positions()
|
||||
self._render_payments()
|
||||
self._render_reminders()
|
||||
@@ -217,6 +246,7 @@ class ClaimTab(ttk.Frame):
|
||||
if str(reminder.get("claim_id", "")) != self.claim_id:
|
||||
continue
|
||||
fee_item = items.get(str(reminder.get("fee_item_id", "")), {})
|
||||
status = str(reminder.get("status", "draft"))
|
||||
self.reminders.insert(
|
||||
"",
|
||||
"end",
|
||||
@@ -224,10 +254,13 @@ class ClaimTab(ttk.Frame):
|
||||
values=(
|
||||
str(reminder.get("created_at", ""))[:16],
|
||||
reminder.get("level", ""),
|
||||
reminder.get("detail", ""),
|
||||
fee_item.get("amount", "0.00"),
|
||||
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
|
||||
display_label(REMINDER_STATUS_LABELS, status),
|
||||
format_date_for_display(str(reminder.get("payment_deadline") or "")),
|
||||
fee_item.get("amount", reminder.get("fee", "0.00")),
|
||||
),
|
||||
)
|
||||
self._update_reminder_buttons()
|
||||
|
||||
def _add_item(self) -> None:
|
||||
ItemDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
@@ -255,6 +288,64 @@ class ClaimTab(ttk.Frame):
|
||||
def _add_reminder(self) -> None:
|
||||
ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
|
||||
def _selected_reminder(self) -> dict | None:
|
||||
selected = self.reminders.selection()
|
||||
if not selected:
|
||||
return None
|
||||
return next(
|
||||
(item for item in self.data.reminders if str(item.get("reminder_id", "")) == selected[0]),
|
||||
None,
|
||||
)
|
||||
|
||||
def _update_reminder_buttons(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
|
||||
state = "normal" if editable else "disabled"
|
||||
self.send_reminder_button.configure(state=state)
|
||||
self.discard_reminder_button.configure(state=state)
|
||||
|
||||
def _send_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
"Mahnung versandt",
|
||||
"Wurde diese Mahnung tatsächlich versandt? Erst jetzt werden Frist und Gebühr gebucht.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.mark_reminder_sent(self.member_id, self.claim_id, str(reminder["reminder_id"]))
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Versand konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _discard_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
"Mahnungsentwurf verwerfen", "Diesen Mahnungsentwurf wirklich verwerfen?", parent=self
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.cancel_reminder(self.member_id, self.claim_id, str(reminder["reminder_id"]))
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Entwurf konnte nicht verworfen werden", str(exc), parent=self)
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _toggle_hold(self) -> None:
|
||||
hold = self.claim.get("dunning_hold") or {}
|
||||
if hold.get("active"):
|
||||
if not messagebox.askyesno("Mahnsperre aufheben", "Mahnsperre wirklich aufheben?", parent=self):
|
||||
return
|
||||
try:
|
||||
self.repository.set_dunning_hold(self.member_id, self.claim_id, active=False)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Mahnsperre", str(exc), parent=self)
|
||||
return
|
||||
self._changed()
|
||||
return
|
||||
DunningHoldDialog(self, self.repository, self.member_id, self.claim_id, self._changed)
|
||||
|
||||
def _cancel_claim(self) -> None:
|
||||
if not messagebox.askyesno(
|
||||
"Forderung stornieren", "Diese Forderung wirklich stornieren?", parent=self
|
||||
@@ -278,11 +369,11 @@ class _Dialog(tk.Toplevel):
|
||||
self.on_saved = on_saved
|
||||
self.title(title)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
self.resizable(False, False)
|
||||
self.frame = ttk.Frame(self, padding=18)
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||
buttons = ttk.Frame(self.frame)
|
||||
@@ -432,32 +523,98 @@ class AllocatePaymentDialog(_Dialog):
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung erfassen", on_saved)
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
self.level_var = tk.IntVar(value=1)
|
||||
policy = repository.get_configuration().get("reminder_policy") or {}
|
||||
levels = policy.get("levels") or [
|
||||
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
|
||||
]
|
||||
self.definition_by_label = {
|
||||
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
|
||||
}
|
||||
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
|
||||
self.detail_var = tk.StringVar()
|
||||
self.fee_var = tk.StringVar(value="0.00")
|
||||
self.fee_var = tk.StringVar()
|
||||
self.deadline_var = tk.StringVar()
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Spinbox(self.frame, from_=1, to=99, textvariable=self.level_var, width=8).grid(
|
||||
row=0, column=1, sticky="w", pady=5
|
||||
level_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.level_var,
|
||||
values=list(self.definition_by_label),
|
||||
state="readonly",
|
||||
width=38,
|
||||
)
|
||||
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
self._buttons(3, self._save)
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.channel_var,
|
||||
values=list(REMINDER_CHANNEL_LABELS.values()),
|
||||
state="readonly",
|
||||
).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
self._load_definition()
|
||||
self._buttons(5, self._save)
|
||||
|
||||
def _load_definition(self) -> None:
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
self.fee_var.set(str(definition.get("fee", "0.00")))
|
||||
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
|
||||
|
||||
def _save(self):
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
try:
|
||||
self.repository.add_reminder(
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=self.level_var.get(),
|
||||
level=int(definition.get("level", 1)),
|
||||
name=str(definition.get("name", "Mahnung")),
|
||||
payment_deadline_days=int(self.deadline_var.get()),
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
except (tk.TclError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
except (ValueError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class DunningHoldDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnsperre setzen", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
self.reason_var = tk.StringVar()
|
||||
self.until_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Grund").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.reason_var, width=45).grid(row=0, column=1, pady=5)
|
||||
ttk.Label(self.frame, text=f"Bis ({date_input_hint()}, optional)").grid(
|
||||
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.until_var).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
self._buttons(2, self._save)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.set_dunning_hold(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
active=True,
|
||||
reason=self.reason_var.get(),
|
||||
until=self.until_var.get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Mahnsperre konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
@@ -17,6 +17,19 @@ CLAIM_ITEM_TYPE_LABELS = {
|
||||
"correction": "Korrektur",
|
||||
}
|
||||
|
||||
REMINDER_STATUS_LABELS = {
|
||||
"draft": "Entwurf",
|
||||
"generated": "Dokument erzeugt",
|
||||
"sent": "Versandt",
|
||||
"cancelled": "Verworfen",
|
||||
}
|
||||
|
||||
REMINDER_CHANNEL_LABELS = {
|
||||
"email": "E-Mail",
|
||||
"letter": "Brief",
|
||||
"personal": "Persönlich",
|
||||
}
|
||||
|
||||
|
||||
def display_label(labels: Mapping[str, str], key: str) -> str:
|
||||
return labels.get(key, key)
|
||||
|
||||
Reference in New Issue
Block a user