mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Compare commits
29
Commits
v0.1.0
..
a61ea3cb57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61ea3cb57 | ||
|
|
aa94145105 | ||
|
|
ade92bfe06 | ||
|
|
6ac205feb8 | ||
|
|
5bacc005f6 | ||
|
|
539859d4c5 | ||
|
|
7a0cdbc04e | ||
|
|
6b62263706 | ||
|
|
ba3238e61a | ||
|
|
c3cdf71506 | ||
|
|
e3f0a1df86 | ||
|
|
6d9c50b148 | ||
|
|
54cb413eff | ||
|
|
cfd5de42f2 | ||
|
|
460dcac77c | ||
|
|
2280a0c658 | ||
|
|
f14011efdc | ||
|
|
060a5aea01 | ||
|
|
fc4eacb591 | ||
|
|
3d3b845d9f | ||
|
|
f4c8ae4f35 | ||
|
|
975cb47d3e | ||
|
|
9942e7aa22 | ||
|
|
68c24219ec | ||
|
|
6a76c7b84b | ||
|
|
7146615499 | ||
|
|
dbae0ce29c | ||
|
|
070684d9bc | ||
|
|
aeaaca1459 |
@@ -1,6 +1,27 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"date": "upcoming",
|
||||
"changes": [
|
||||
"Mitgliedsanträge und Mitgliederakten enthalten jetzt ein separates Feld für das Antragsdatum.",
|
||||
"Eigentum und aktueller Gewahrsam von Assets werden getrennt dokumentiert; neben Vereinseigentum kann nun auch Eigentum von Mitgliedern oder externen Personen zentral erfasst werden.",
|
||||
"Mitgliedseigentum wird per UUID mit der Mitgliedsakte verknüpft und erscheint dort gemeinsam mit den vom Verein erhaltenen Assets in getrennt verständlichen Ansichten.",
|
||||
"Standort, Übergabedatum, Zustand und Wert eines Assets können für Übergabe- und Versicherungsnachweise festgehalten werden; Eigentumsänderungen erscheinen zusätzlich in der Chronik.",
|
||||
"Forderungen können nachträglich bearbeitet werden, ohne zusätzliche Forderungspositionen oder bereits verbuchte Zahlungen zu verlieren.",
|
||||
"Zahlungen können bearbeitet, neu auf mehrere Forderungen verteilt oder einschließlich ihrer Zuordnungen gelöscht werden.",
|
||||
"Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.",
|
||||
"Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus.",
|
||||
"Die Mitgliederliste zeigt standardmäßig aktive Mitglieder, ordnet Nachname vor Vorname an und sortiert aufsteigend nach Nachnamen.",
|
||||
"Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben.",
|
||||
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
|
||||
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
||||
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
||||
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
|
||||
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"date": "2026-06-27",
|
||||
"changes": [
|
||||
"First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.",
|
||||
|
||||
@@ -117,18 +117,24 @@ def validate_member_dates(
|
||||
birth_date: str,
|
||||
accepted_at: str = "",
|
||||
membership_started_at: str = "",
|
||||
membership_ended_at: str = "",
|
||||
today: date | None = None,
|
||||
) -> None:
|
||||
reference = today or date.today()
|
||||
birth = validate_birth_date(birth_date, today=reference)
|
||||
accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference)
|
||||
started = _validate_not_future(membership_started_at, "Mitglied seit", reference)
|
||||
ended = parse_iso_date(membership_ended_at, "Austrittsdatum")
|
||||
if birth and accepted and accepted < birth:
|
||||
raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.")
|
||||
if birth and started and started < birth:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.")
|
||||
if accepted and started and started < accepted:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.")
|
||||
if started and ended and ended < started:
|
||||
raise DateValidationError("Austrittsdatum darf nicht vor dem Mitgliedschaftsbeginn liegen.")
|
||||
if accepted and ended and ended < accepted:
|
||||
raise DateValidationError("Austrittsdatum darf nicht vor dem Aufnahmebeschluss liegen.")
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, on_date: date | None = None) -> int:
|
||||
|
||||
@@ -20,6 +20,13 @@ MEMBERSHIP_STATUS_LABELS = {
|
||||
"ended": "BEENDET",
|
||||
}
|
||||
|
||||
PAYMENT_FREQUENCY_LABELS = {
|
||||
"monthly": "MONATLICH",
|
||||
"quarterly": "QUARTALSWEISE",
|
||||
"semiannual": "HALBJÄHRLICH",
|
||||
"annual": "JÄHRLICH",
|
||||
}
|
||||
|
||||
ASSET_STATUS_LABELS = {
|
||||
"available": "VERFUEGBAR",
|
||||
"issued": "AUSGEGEBEN",
|
||||
@@ -27,6 +34,19 @@ ASSET_STATUS_LABELS = {
|
||||
"retired": "AUSGEMUSTERT",
|
||||
}
|
||||
|
||||
ASSET_OWNER_TYPE_LABELS = {
|
||||
"club": "VEREIN",
|
||||
"member": "MITGLIED",
|
||||
"external": "EXTERN",
|
||||
}
|
||||
|
||||
ASSET_CUSTODY_TYPE_LABELS = {
|
||||
"club": "VEREIN",
|
||||
"member": "MITGLIED",
|
||||
"external": "EXTERN",
|
||||
"unknown": "UNBEKANNT",
|
||||
}
|
||||
|
||||
HOUSEKEEPER_MEMBER_FIELD_LABELS = {
|
||||
"nickname": "Nickname",
|
||||
"email": "E-Mail-Adresse",
|
||||
@@ -75,9 +95,11 @@ class Member:
|
||||
mandate_active: bool = False
|
||||
mandate_revoked_at: str = ""
|
||||
status: str = "application"
|
||||
application_date: str = ""
|
||||
accepted_at: str = ""
|
||||
membership_started_at: str = ""
|
||||
payment_frequency: str = "annual"
|
||||
membership_ended_at: str = ""
|
||||
payment_frequency: str = "semiannual"
|
||||
contribution_rule_id: str = "standard-2022"
|
||||
honorary: bool = False
|
||||
notes: str = ""
|
||||
@@ -120,8 +142,10 @@ class Member:
|
||||
},
|
||||
"membership": {
|
||||
"status": self.status,
|
||||
"application_date": self.application_date,
|
||||
"accepted_at": self.accepted_at,
|
||||
"started_at": self.membership_started_at,
|
||||
"ended_at": self.membership_ended_at,
|
||||
"honorary": self.honorary,
|
||||
},
|
||||
"contribution_profile": {
|
||||
@@ -163,11 +187,13 @@ class Member:
|
||||
mandate_active=bool(banking.get("mandate_active", False)),
|
||||
mandate_revoked_at=str(banking.get("mandate_revoked_at", "")),
|
||||
status=str(membership.get("status", "application")),
|
||||
application_date=str(membership.get("application_date", "")),
|
||||
accepted_at=str(membership.get("accepted_at", "")),
|
||||
membership_started_at=str(membership.get("started_at", "")),
|
||||
membership_ended_at=str(membership.get("ended_at", "")),
|
||||
honorary=bool(membership.get("honorary", False)),
|
||||
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
|
||||
payment_frequency=str(contribution.get("payment_frequency", "annual")),
|
||||
payment_frequency=str(contribution.get("payment_frequency", "semiannual")),
|
||||
notes=str(data.get("notes", "")),
|
||||
created_at=str(data.get("created_at", _iso_now())),
|
||||
updated_at=str(data.get("updated_at", _iso_now())),
|
||||
@@ -183,6 +209,14 @@ class Asset:
|
||||
serial_number: str = ""
|
||||
status: str = "available"
|
||||
current_holder_member_id: str = ""
|
||||
owner_type: str = "club"
|
||||
owner_member_id: str = ""
|
||||
owner_name: str = ""
|
||||
custody_type: str = "club"
|
||||
location: str = ""
|
||||
handed_over_at: str = ""
|
||||
condition: str = ""
|
||||
estimated_value: str = ""
|
||||
deposit_amount_default: str = "0.00"
|
||||
notes: str = ""
|
||||
created_at: str = field(default_factory=_iso_now)
|
||||
@@ -199,6 +233,14 @@ class Asset:
|
||||
"serial_number": self.serial_number,
|
||||
"status": self.status,
|
||||
"current_holder_member_id": self.current_holder_member_id,
|
||||
"owner_type": self.owner_type,
|
||||
"owner_member_id": self.owner_member_id,
|
||||
"owner_name": self.owner_name,
|
||||
"custody_type": self.custody_type,
|
||||
"location": self.location,
|
||||
"handed_over_at": self.handed_over_at,
|
||||
"condition": self.condition,
|
||||
"estimated_value": self.estimated_value,
|
||||
"deposit_amount_default": self.deposit_amount_default,
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at,
|
||||
@@ -216,6 +258,19 @@ class Asset:
|
||||
serial_number=str(data.get("serial_number", "")),
|
||||
status=str(data.get("status", "available")),
|
||||
current_holder_member_id=str(data.get("current_holder_member_id", "")),
|
||||
owner_type=str(data.get("owner_type", "club")),
|
||||
owner_member_id=str(data.get("owner_member_id", "")),
|
||||
owner_name=str(data.get("owner_name", "")),
|
||||
custody_type=str(
|
||||
data.get(
|
||||
"custody_type",
|
||||
"member" if data.get("current_holder_member_id") else "club",
|
||||
)
|
||||
),
|
||||
location=str(data.get("location", "")),
|
||||
handed_over_at=str(data.get("handed_over_at", "")),
|
||||
condition=str(data.get("condition", "")),
|
||||
estimated_value=str(data.get("estimated_value", "")),
|
||||
deposit_amount_default=str(data.get("deposit_amount_default", "0.00")),
|
||||
notes=str(data.get("notes", "")),
|
||||
created_at=str(data.get("created_at", _iso_now())),
|
||||
|
||||
@@ -37,6 +37,7 @@ def evaluate(context: RuleContext):
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
membership_ended_at=member.membership_ended_at,
|
||||
today=context.today,
|
||||
)
|
||||
except DateValidationError as exc:
|
||||
|
||||
@@ -9,6 +9,21 @@ from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
||||
RULE_ID = "contribution-claims"
|
||||
ORDER = 40
|
||||
CENT = Decimal("0.01")
|
||||
MONTH_NAMES = (
|
||||
"",
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
)
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
@@ -18,6 +33,7 @@ def evaluate(context: RuleContext):
|
||||
try:
|
||||
accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss")
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at
|
||||
ended_at = parse_iso_date(member.membership_ended_at, "Austrittsdatum")
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not accepted_at or not started_at:
|
||||
@@ -29,20 +45,22 @@ def evaluate(context: RuleContext):
|
||||
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", "")},
|
||||
},
|
||||
admission_due = accepted_at + timedelta(days=due_days)
|
||||
if not ended_at or admission_due <= ended_at:
|
||||
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": admission_due.isoformat(),
|
||||
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
year_from = (
|
||||
started_at.year
|
||||
@@ -50,11 +68,17 @@ def evaluate(context: RuleContext):
|
||||
else context.today.year
|
||||
)
|
||||
for year in range(year_from, context.today.year + 2):
|
||||
actions.extend(_membership_claims(context, started_at, accepted_at, year))
|
||||
actions.extend(_membership_claims(context, started_at, accepted_at, ended_at, year))
|
||||
return actions
|
||||
|
||||
|
||||
def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int):
|
||||
def _membership_claims(
|
||||
context: RuleContext,
|
||||
started_at: date,
|
||||
accepted_at: date,
|
||||
ended_at: date | None,
|
||||
year: int,
|
||||
):
|
||||
member = context.member
|
||||
period_start = max(started_at, date(year, 1, 1))
|
||||
if period_start.year > year:
|
||||
@@ -76,6 +100,26 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
("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")),
|
||||
]
|
||||
elif member.payment_frequency == "quarterly":
|
||||
periods = [
|
||||
(
|
||||
f"quarter-{quarter}",
|
||||
first_month,
|
||||
first_month + 2,
|
||||
_recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"),
|
||||
)
|
||||
for quarter, first_month in enumerate((1, 4, 7, 10), start=1)
|
||||
]
|
||||
elif member.payment_frequency == "monthly":
|
||||
periods = [
|
||||
(
|
||||
f"month-{month:02d}",
|
||||
month,
|
||||
month,
|
||||
_recurring_due_date(year, month, rule.get("annual_due"), "01-31"),
|
||||
)
|
||||
for month in range(1, 13)
|
||||
]
|
||||
else:
|
||||
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
||||
|
||||
@@ -96,7 +140,14 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
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)
|
||||
if ended_at and due_date > ended_at:
|
||||
continue
|
||||
claim_key = f"membership-fee:{year}:{suffix}"
|
||||
full_period_months = last_month - first_month + 1
|
||||
description = _title(year, suffix)
|
||||
if months < full_period_months:
|
||||
unit = "Monat" if months == 1 else "Monate"
|
||||
description = f"{description} ({months} {unit})"
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
@@ -105,6 +156,7 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
payload={
|
||||
"type": "membership_fee",
|
||||
"title": _title(year, suffix),
|
||||
"description": description,
|
||||
"amount": _money(amount),
|
||||
"due_date": due_date.isoformat(),
|
||||
"service_period": {
|
||||
@@ -151,7 +203,21 @@ def _due_date(year: int, value, fallback: str) -> date:
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def _recurring_due_date(year: int, month: int, value, fallback: str) -> date:
|
||||
try:
|
||||
_configured_month, day = (int(part) for part in str(value or fallback).split("-", 1))
|
||||
except (TypeError, ValueError):
|
||||
day = int(fallback.split("-", 1)[1])
|
||||
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def _title(year: int, suffix: str) -> str:
|
||||
if suffix.startswith("month-"):
|
||||
month = int(suffix.removeprefix("month-"))
|
||||
return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}"
|
||||
if suffix.startswith("quarter-"):
|
||||
quarter = int(suffix.removeprefix("quarter-"))
|
||||
return f"Mitgliedsbeitrag {quarter}. Quartal {year}"
|
||||
if suffix == "first-half":
|
||||
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
|
||||
if suffix == "second-half":
|
||||
|
||||
@@ -211,6 +211,7 @@ def _template_values(
|
||||
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
"member.accepted_at": format_date_for_display(member.accepted_at),
|
||||
"member.started_at": format_date_for_display(member.membership_started_at),
|
||||
"member.ended_at": format_date_for_display(member.membership_ended_at),
|
||||
"member.street": member.street,
|
||||
"member.address_addition": member.address_addition,
|
||||
"member.postal_code": member.postal_code,
|
||||
|
||||
@@ -324,7 +324,9 @@ class Housekeeper:
|
||||
{
|
||||
"item_id": str(uuid4()),
|
||||
"type": "base",
|
||||
"description": str(claim.get("title", claim_key)),
|
||||
"description": str(
|
||||
claim.get("description") or claim.get("title", claim_key)
|
||||
),
|
||||
"quantity": "1.00",
|
||||
"unit_price": str(claim.get("amount", "0.00")),
|
||||
"amount": str(claim.get("amount", "0.00")),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedReminderMail:
|
||||
export_path: Path
|
||||
archive_path: Path
|
||||
sha256: str
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return cleaned or "Mahnung"
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
|
||||
|
||||
def reminder_mail_bytes(
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
reminder: dict,
|
||||
claim: dict,
|
||||
balance: str,
|
||||
organization: dict,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
iban = str(organization.get("iban", "")).strip()
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
|
||||
detail = str(reminder.get("detail", "")).strip()
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {first_name.strip() or 'Mitglied'},",
|
||||
"",
|
||||
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ "
|
||||
"ist noch ein Betrag offen.",
|
||||
"",
|
||||
f"Offener Betrag: {balance} Euro",
|
||||
f"Ursprünglich fällig: "
|
||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
||||
f"Neue Zahlungsfrist: "
|
||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
||||
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
|
||||
f"Hinweis: {detail}" if detail else "",
|
||||
"",
|
||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
||||
payment_details,
|
||||
"",
|
||||
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
|
||||
"betrachte sie bitte als gegenstandslos.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_and_send_reminder_mail(
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
reminder_id: str,
|
||||
*,
|
||||
output_path: Path | str,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> GeneratedReminderMail:
|
||||
member = repository.get_member(member_id)
|
||||
data, claim = repository.get_claim(member_id, claim_id)
|
||||
reminder = repository._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "")) not in {"draft", "generated"}:
|
||||
raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.")
|
||||
if not member.email.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||
|
||||
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = (
|
||||
f"{datetime.now().date().isoformat()}-"
|
||||
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
|
||||
)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
archive_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
os.replace(archive_temp, archive_path)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
repository.append_event(
|
||||
member_id,
|
||||
event_type="reminder_email_sent",
|
||||
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={
|
||||
"claim_id": claim_id,
|
||||
"reminder_id": reminder_id,
|
||||
"document": relative_path,
|
||||
},
|
||||
data={
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
||||
},
|
||||
)
|
||||
return GeneratedReminderMail(target, archive_path, digest)
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||||
|
||||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||||
SEPA_CHARACTERS = frozenset(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
"':?,-(+.)/ ÄäÖöÜüß&*$%"
|
||||
)
|
||||
ET.register_namespace("", PAIN_NAMESPACE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectDebit:
|
||||
member_id: str
|
||||
member_number: str
|
||||
account_holder: str
|
||||
iban: str
|
||||
bic: str
|
||||
mandate_reference: str
|
||||
mandate_signed_at: str
|
||||
amount: Decimal
|
||||
purpose: str
|
||||
claim_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def _safe(value: Any, maximum: int) -> str:
|
||||
replacements = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'", "–": "-", "—": "-"})
|
||||
source = str(value).translate(replacements)
|
||||
normalized: list[str] = []
|
||||
for character in source:
|
||||
if character in SEPA_CHARACTERS:
|
||||
normalized.append(character)
|
||||
continue
|
||||
decomposed = unicodedata.normalize("NFKD", character)
|
||||
replacement = "".join(
|
||||
part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS
|
||||
)
|
||||
normalized.append(replacement or " ")
|
||||
return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum]
|
||||
|
||||
|
||||
def pending_direct_debits(
|
||||
repository: MemberRepository, *, due_until: date, due_from: date | None = None
|
||||
) -> tuple[list[DirectDebit], list[str]]:
|
||||
if due_from and due_from > due_until:
|
||||
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
|
||||
debits: list[DirectDebit] = []
|
||||
warnings: list[str] = []
|
||||
for member in repository.list_members():
|
||||
if not member.mandate_active:
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for value, label in (
|
||||
(member.iban, "IBAN"),
|
||||
(member.mandate_reference, "Mandatsreferenz"),
|
||||
(member.mandate_signed_at, "Mandatsdatum"),
|
||||
)
|
||||
if not str(value).strip()
|
||||
]
|
||||
if missing:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
||||
continue
|
||||
try:
|
||||
validate_iban(member.iban)
|
||||
date.fromisoformat(member.mandate_signed_at)
|
||||
except (RepositoryError, ValueError) as exc:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||
continue
|
||||
data = repository.get_contributions(member.member_id)
|
||||
selected = []
|
||||
amount = Decimal("0")
|
||||
for claim in data.claims:
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
continue
|
||||
try:
|
||||
claim_due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
balance = claim_balance(data, claim)
|
||||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not selected:
|
||||
continue
|
||||
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
|
||||
purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140)
|
||||
debits.append(
|
||||
DirectDebit(
|
||||
member_id=member.member_id,
|
||||
member_number=member.member_number,
|
||||
account_holder=member.account_holder.strip() or member.display_name,
|
||||
iban=member.iban.replace(" ", "").upper(),
|
||||
bic=member.bic.replace(" ", "").upper(),
|
||||
mandate_reference=_safe(member.mandate_reference, 35),
|
||||
mandate_signed_at=member.mandate_signed_at,
|
||||
amount=amount.quantize(Decimal("0.01")),
|
||||
purpose=purpose,
|
||||
claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected),
|
||||
)
|
||||
)
|
||||
return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings
|
||||
|
||||
|
||||
def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str:
|
||||
target = io.StringIO(newline="")
|
||||
writer = csv.writer(target, delimiter=";", lineterminator="\r\n")
|
||||
writer.writerow(
|
||||
["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag",
|
||||
"Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"]
|
||||
)
|
||||
for debit in debits:
|
||||
writer.writerow(
|
||||
[collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR",
|
||||
debit.mandate_reference, debit.mandate_signed_at, debit.purpose]
|
||||
)
|
||||
return target.getvalue()
|
||||
|
||||
|
||||
def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None:
|
||||
Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig")
|
||||
|
||||
|
||||
def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element:
|
||||
child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}")
|
||||
if text is not None:
|
||||
child.text = str(text)
|
||||
return child
|
||||
|
||||
|
||||
def pain008_bytes(
|
||||
debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any],
|
||||
message_id: str | None = None, created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not debits:
|
||||
raise RepositoryError("Es gibt keine Lastschriften für den Export.")
|
||||
creditor_name = _safe(organization.get("name", ""), 70)
|
||||
creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper()
|
||||
creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper()
|
||||
creditor_id = _safe(organization.get("creditor_id", ""), 35)
|
||||
missing = [label for value, label in (
|
||||
(creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"),
|
||||
(creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"),
|
||||
) if not value]
|
||||
if missing:
|
||||
raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".")
|
||||
validate_iban(creditor_iban)
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35)
|
||||
total = sum((item.amount for item in debits), Decimal("0"))
|
||||
document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document")
|
||||
initiation = _el(document, "CstmrDrctDbtInitn")
|
||||
header = _el(initiation, "GrpHdr")
|
||||
for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")),
|
||||
("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")):
|
||||
_el(header, name, value)
|
||||
_el(_el(header, "InitgPty"), "Nm", creditor_name)
|
||||
payment = _el(initiation, "PmtInf")
|
||||
for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"),
|
||||
("BtchBookg", "true"), ("NbOfTxs", len(debits)),
|
||||
("CtrlSum", f"{total:.2f}")):
|
||||
_el(payment, name, value)
|
||||
payment_type = _el(payment, "PmtTpInf")
|
||||
_el(_el(payment_type, "SvcLvl"), "Cd", "SEPA")
|
||||
_el(_el(payment_type, "LclInstrm"), "Cd", "CORE")
|
||||
_el(payment_type, "SeqTp", "RCUR")
|
||||
_el(payment, "ReqdColltnDt", collection_date.isoformat())
|
||||
_el(_el(payment, "Cdtr"), "Nm", creditor_name)
|
||||
_el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban)
|
||||
_el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic)
|
||||
_el(payment, "ChrgBr", "SLEV")
|
||||
for index, debit in enumerate(debits, start=1):
|
||||
transaction = _el(payment, "DrctDbtTxInf")
|
||||
_el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35))
|
||||
amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}")
|
||||
amount.set("Ccy", "EUR")
|
||||
direct_debit = _el(transaction, "DrctDbtTx")
|
||||
mandate = _el(direct_debit, "MndtRltdInf")
|
||||
_el(mandate, "MndtId", debit.mandate_reference)
|
||||
_el(mandate, "DtOfSgntr", debit.mandate_signed_at)
|
||||
other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr")
|
||||
_el(other, "Id", creditor_id)
|
||||
_el(_el(other, "SchmeNm"), "Prtry", "SEPA")
|
||||
financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId")
|
||||
if debit.bic:
|
||||
_el(financial, "BICFI", debit.bic)
|
||||
else:
|
||||
_el(_el(financial, "Othr"), "Id", "NOTPROVIDED")
|
||||
_el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70))
|
||||
_el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban)
|
||||
_el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose)
|
||||
ET.indent(document, space=" ")
|
||||
return ET.tostring(document, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def write_pain008(
|
||||
path: Path | str, debits: list[DirectDebit], *, collection_date: date,
|
||||
organization: dict[str, Any],
|
||||
) -> None:
|
||||
Path(path).write_bytes(
|
||||
pain008_bytes(debits, collection_date=collection_date, organization=organization)
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.services.sepa import DirectDebit
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedDebitMail:
|
||||
member_id: str
|
||||
recipient: str
|
||||
export_path: Path
|
||||
archive_path: Path
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return cleaned or "Mitglied"
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
|
||||
|
||||
def debit_mail_bytes(
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
debit: DirectDebit,
|
||||
collection_date: date,
|
||||
creditor_id: str,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Info-Mails ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"SEPA-Lastschrift C3MA – {collection_date.strftime('%d.%m.%Y')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
greeting = first_name.strip() or "Mitglied"
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {greeting},",
|
||||
"",
|
||||
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
|
||||
"SEPA-Lastschrift einziehen werden:",
|
||||
"",
|
||||
f"Betrag: {debit.amount:.2f} Euro",
|
||||
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
|
||||
f"Verwendungszweck: {debit.purpose}",
|
||||
f"Mandatsreferenz: {debit.mandate_reference}",
|
||||
f"Gläubiger-Identifikationsnummer: {creditor_id}",
|
||||
"",
|
||||
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_debit_mails(
|
||||
repository: MemberRepository,
|
||||
debits: list[DirectDebit],
|
||||
*,
|
||||
collection_date: date,
|
||||
output_directory: Path | str,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
creditor_id = str(organization.get("creditor_id", "")).strip()
|
||||
if not creditor_id:
|
||||
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
||||
generated: list[GeneratedDebitMail] = []
|
||||
warnings: list[str] = []
|
||||
for debit in debits:
|
||||
member = repository.get_member(debit.member_id)
|
||||
if not member.email.strip():
|
||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||
continue
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=collection_date,
|
||||
creditor_id=creditor_id,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
filename = (
|
||||
f"SEPA-Info-{collection_date.isoformat()}-"
|
||||
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
||||
)
|
||||
export_path = _available_path(output, filename)
|
||||
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
export_path.write_bytes(content)
|
||||
try:
|
||||
archive_path.write_bytes(content)
|
||||
except OSError:
|
||||
export_path.unlink(missing_ok=True)
|
||||
raise
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member.member_id / "files"
|
||||
).as_posix()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="sepa_notification_generated",
|
||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"document": relative_path},
|
||||
data={
|
||||
"sha256": digest,
|
||||
"recipient": member.email,
|
||||
"collection_date": collection_date.isoformat(),
|
||||
"amount": f"{debit.amount:.2f}",
|
||||
},
|
||||
)
|
||||
generated.append(
|
||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
||||
)
|
||||
return generated, warnings
|
||||
+569
-32
@@ -2,15 +2,19 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_total,
|
||||
credit_allocated_total,
|
||||
@@ -21,8 +25,11 @@ from ccma.domain.contributions import (
|
||||
)
|
||||
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
|
||||
from ccma.domain.models import (
|
||||
ASSET_CUSTODY_TYPE_LABELS,
|
||||
ASSET_OWNER_TYPE_LABELS,
|
||||
ASSET_STATUS_LABELS,
|
||||
MEMBERSHIP_STATUS_LABELS,
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
Asset,
|
||||
ContributionData,
|
||||
Event,
|
||||
@@ -89,6 +96,7 @@ DEFAULT_CONFIGURATION = {
|
||||
"member_number_policy": {
|
||||
"mode": "automatic",
|
||||
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
|
||||
"allocation_strategy": "next_free",
|
||||
},
|
||||
"member_number_sequences": {},
|
||||
"reminder_policy": {
|
||||
@@ -134,9 +142,7 @@ class MemberRepository:
|
||||
if builtin_templates.is_dir():
|
||||
for source in builtin_templates.iterdir():
|
||||
destination_name = (
|
||||
"Forderung mit Positionen.fodt"
|
||||
if source.name == "Forderung.fodt"
|
||||
else source.name
|
||||
"Forderung mit Positionen.fodt" if source.name == "Forderung.fodt" else source.name
|
||||
)
|
||||
destination = templates_root / destination_name
|
||||
if source.is_file() and not destination.exists():
|
||||
@@ -158,6 +164,8 @@ class MemberRepository:
|
||||
policy = config.get("member_number_policy") or {}
|
||||
if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}:
|
||||
errors.append("repository.json: ungültiger Mitgliedsnummernmodus")
|
||||
if str(policy.get("allocation_strategy", "next_free")) not in {"next_free", "max_plus_one"}:
|
||||
errors.append("repository.json: ungültige Vergabestrategie für Mitgliedsnummern")
|
||||
validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc:
|
||||
errors.append(f"repository.json: {exc}")
|
||||
@@ -167,13 +175,13 @@ class MemberRepository:
|
||||
try:
|
||||
member, _contributions = self.preflight_member_record(member_dir.name)
|
||||
errors.extend(
|
||||
f"{member_dir.name}/{warning}"
|
||||
for warning in self.member_hash_warnings(member_dir.name)
|
||||
f"{member_dir.name}/{warning}" for warning in self.member_hash_warnings(member_dir.name)
|
||||
)
|
||||
validate_member_dates(
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
membership_ended_at=member.membership_ended_at,
|
||||
)
|
||||
if member.member_id != member_dir.name:
|
||||
errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein")
|
||||
@@ -199,8 +207,7 @@ class MemberRepository:
|
||||
try:
|
||||
asset = self.get_asset(asset_dir.name)
|
||||
errors.extend(
|
||||
f"{asset_dir.name}/{warning}"
|
||||
for warning in self.asset_hash_warnings(asset_dir.name)
|
||||
f"{asset_dir.name}/{warning}" for warning in self.asset_hash_warnings(asset_dir.name)
|
||||
)
|
||||
if asset.asset_id != asset_dir.name:
|
||||
errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein")
|
||||
@@ -219,6 +226,17 @@ class MemberRepository:
|
||||
)
|
||||
elif asset.status == "issued":
|
||||
errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id")
|
||||
if asset.owner_type not in ASSET_OWNER_TYPE_LABELS:
|
||||
errors.append(f"{asset_dir.name}/asset.json: ungültiger Eigentümertyp")
|
||||
if asset.custody_type not in ASSET_CUSTODY_TYPE_LABELS:
|
||||
errors.append(f"{asset_dir.name}/asset.json: ungültiger Gewahrsamstyp")
|
||||
if asset.owner_type == "member":
|
||||
if asset.owner_member_id:
|
||||
self.get_member(asset.owner_member_id)
|
||||
else:
|
||||
errors.append(
|
||||
f"{asset_dir.name}/asset.json: Mitgliedseigentum benötigt owner_member_id"
|
||||
)
|
||||
self.get_asset_events(asset.asset_id)
|
||||
except RepositoryError as exc:
|
||||
errors.append(str(exc))
|
||||
@@ -277,12 +295,38 @@ class MemberRepository:
|
||||
email: str = "",
|
||||
phone: str = "",
|
||||
birth_date: str = "",
|
||||
application_date: str = "",
|
||||
member_number: str = "",
|
||||
) -> Member:
|
||||
with self._member_number_lock():
|
||||
return self._create_member_locked(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
nickname=nickname,
|
||||
email=email,
|
||||
phone=phone,
|
||||
birth_date=birth_date,
|
||||
application_date=application_date,
|
||||
member_number=member_number,
|
||||
)
|
||||
|
||||
def _create_member_locked(
|
||||
self,
|
||||
*,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
nickname: str,
|
||||
email: str,
|
||||
phone: str,
|
||||
birth_date: str,
|
||||
application_date: str,
|
||||
member_number: str,
|
||||
) -> Member:
|
||||
if not first_name.strip() or not last_name.strip():
|
||||
raise RepositoryError("Vorname und Nachname sind erforderlich.")
|
||||
try:
|
||||
birth_date = normalize_date_input(birth_date, "Geburtsdatum")
|
||||
application_date = normalize_date_input(application_date, "Antragsdatum")
|
||||
validate_member_dates(birth_date=birth_date)
|
||||
except DateValidationError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
@@ -293,7 +337,9 @@ class MemberRepository:
|
||||
elif policy["mode"] == "manual":
|
||||
raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.")
|
||||
else:
|
||||
selected_number = self._allocate_member_number(policy["pattern"])
|
||||
selected_number = self._allocate_member_number(
|
||||
policy["pattern"], policy["allocation_strategy"]
|
||||
)
|
||||
member_id = str(uuid4())
|
||||
directory = self._member_path(member_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
@@ -307,6 +353,7 @@ class MemberRepository:
|
||||
email=email.strip(),
|
||||
phone=phone.strip(),
|
||||
birth_date=birth_date,
|
||||
application_date=application_date,
|
||||
)
|
||||
write_json_atomic(directory / "member.json", member.to_dict())
|
||||
write_json_atomic(directory / "contributions.json", ContributionData().to_dict())
|
||||
@@ -323,8 +370,10 @@ class MemberRepository:
|
||||
existing = self.get_member(member.member_id)
|
||||
try:
|
||||
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
|
||||
member.application_date = normalize_date_input(member.application_date, "Antragsdatum")
|
||||
member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss")
|
||||
member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit")
|
||||
member.membership_ended_at = normalize_date_input(member.membership_ended_at, "Austrittsdatum")
|
||||
member.mandate_signed_at = normalize_date_input(member.mandate_signed_at, "Mandat erteilt am")
|
||||
member.mandate_revoked_at = normalize_date_input(
|
||||
member.mandate_revoked_at, "Mandat widerrufen am"
|
||||
@@ -333,7 +382,10 @@ class MemberRepository:
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
membership_ended_at=member.membership_ended_at,
|
||||
)
|
||||
if member.membership_ended_at and date.fromisoformat(member.membership_ended_at) <= date.today():
|
||||
member.status = "ended"
|
||||
except DateValidationError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
member.iban = normalize_iban(member.iban)
|
||||
@@ -346,6 +398,8 @@ class MemberRepository:
|
||||
raise RepositoryError(
|
||||
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
|
||||
)
|
||||
if member.payment_frequency not in PAYMENT_FREQUENCY_LABELS:
|
||||
raise RepositoryError("Ungültige Zahlweise.")
|
||||
if member.member_number != existing.member_number:
|
||||
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
|
||||
changes = self._summarize_changes(existing, member)
|
||||
@@ -393,6 +447,14 @@ class MemberRepository:
|
||||
serial_number: str = "",
|
||||
deposit_amount_default: str = "0",
|
||||
notes: str = "",
|
||||
owner_type: str = "club",
|
||||
owner_member_id: str = "",
|
||||
owner_name: str = "",
|
||||
custody_type: str = "club",
|
||||
location: str = "",
|
||||
handed_over_at: str = "",
|
||||
condition: str = "",
|
||||
estimated_value: str = "",
|
||||
) -> Asset:
|
||||
if not label.strip():
|
||||
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
|
||||
@@ -402,6 +464,7 @@ class MemberRepository:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if deposit_amount < 0:
|
||||
raise RepositoryError("Die Kaution darf nicht negativ sein.")
|
||||
self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
|
||||
asset_id = str(uuid4())
|
||||
directory = self._asset_path(asset_id)
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
@@ -414,6 +477,14 @@ class MemberRepository:
|
||||
serial_number=serial_number.strip(),
|
||||
deposit_amount_default=money_text(deposit_amount),
|
||||
notes=notes.strip(),
|
||||
owner_type=owner_type,
|
||||
owner_member_id=owner_member_id,
|
||||
owner_name=owner_name.strip(),
|
||||
custody_type=custody_type,
|
||||
location=location.strip(),
|
||||
handed_over_at=handed_over_at.strip(),
|
||||
condition=condition.strip(),
|
||||
estimated_value=estimated_value.strip(),
|
||||
)
|
||||
write_json_atomic(directory / "asset.json", asset.to_dict())
|
||||
self.append_asset_event(
|
||||
@@ -423,6 +494,15 @@ class MemberRepository:
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
)
|
||||
if asset.owner_type == "member":
|
||||
self.append_event(
|
||||
asset.owner_member_id,
|
||||
event_type="member_asset_registered",
|
||||
summary=f"Privateigentum im Inventar erfasst: {asset.label}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"asset_id": asset.asset_id},
|
||||
)
|
||||
return asset
|
||||
|
||||
def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None:
|
||||
@@ -431,15 +511,21 @@ class MemberRepository:
|
||||
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
|
||||
if asset.status not in ASSET_STATUS_LABELS:
|
||||
raise RepositoryError("Ungültiger Asset-Status.")
|
||||
self._validate_asset_relationships(
|
||||
asset.owner_type,
|
||||
asset.owner_member_id,
|
||||
asset.owner_name,
|
||||
asset.custody_type,
|
||||
asset.current_holder_member_id,
|
||||
)
|
||||
try:
|
||||
deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if deposit_amount < 0:
|
||||
raise RepositoryError("Die Kaution darf nicht negativ sein.")
|
||||
if (
|
||||
existing.current_holder_member_id
|
||||
and money_text(deposit_amount) != str(existing.deposit_amount_default)
|
||||
if existing.current_holder_member_id and money_text(deposit_amount) != str(
|
||||
existing.deposit_amount_default
|
||||
):
|
||||
raise RepositoryError(
|
||||
"Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist."
|
||||
@@ -450,6 +536,11 @@ class MemberRepository:
|
||||
asset.serial_number = asset.serial_number.strip()
|
||||
asset.deposit_amount_default = money_text(deposit_amount)
|
||||
asset.notes = asset.notes.strip()
|
||||
asset.owner_name = asset.owner_name.strip()
|
||||
asset.location = asset.location.strip()
|
||||
asset.handed_over_at = asset.handed_over_at.strip()
|
||||
asset.condition = asset.condition.strip()
|
||||
asset.estimated_value = asset.estimated_value.strip()
|
||||
if asset.current_holder_member_id:
|
||||
self.get_member(asset.current_holder_member_id)
|
||||
if asset.status != "issued":
|
||||
@@ -467,6 +558,25 @@ class MemberRepository:
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
)
|
||||
if existing.owner_member_id != asset.owner_member_id:
|
||||
if existing.owner_type == "member" and existing.owner_member_id:
|
||||
self.append_event(
|
||||
existing.owner_member_id,
|
||||
event_type="member_asset_ownership_ended",
|
||||
summary=f"Nicht mehr als Privateigentum geführt: {asset.label}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"asset_id": asset.asset_id},
|
||||
)
|
||||
if asset.owner_type == "member" and asset.owner_member_id:
|
||||
self.append_event(
|
||||
asset.owner_member_id,
|
||||
event_type="member_asset_registered",
|
||||
summary=f"Privateigentum im Inventar erfasst: {asset.label}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"asset_id": asset.asset_id},
|
||||
)
|
||||
|
||||
def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset:
|
||||
asset = self.get_asset(asset_id)
|
||||
@@ -476,6 +586,7 @@ class MemberRepository:
|
||||
if asset.status in {"lost", "retired"}:
|
||||
raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.")
|
||||
asset.current_holder_member_id = member.member_id
|
||||
asset.custody_type = "member"
|
||||
asset.status = "issued"
|
||||
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
@@ -503,6 +614,7 @@ class MemberRepository:
|
||||
if not member_id:
|
||||
raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.")
|
||||
asset.current_holder_member_id = ""
|
||||
asset.custody_type = "club"
|
||||
asset.status = "available"
|
||||
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
||||
@@ -528,6 +640,41 @@ class MemberRepository:
|
||||
self.get_member(member_id)
|
||||
return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id]
|
||||
|
||||
def list_member_owned_assets(self, member_id: str) -> list[Asset]:
|
||||
self.get_member(member_id)
|
||||
return [
|
||||
asset
|
||||
for asset in self.list_assets()
|
||||
if asset.owner_type == "member" and asset.owner_member_id == member_id
|
||||
]
|
||||
|
||||
def _validate_asset_relationships(
|
||||
self,
|
||||
owner_type: str,
|
||||
owner_member_id: str,
|
||||
owner_name: str,
|
||||
custody_type: str,
|
||||
holder_member_id: str,
|
||||
) -> None:
|
||||
if owner_type not in ASSET_OWNER_TYPE_LABELS:
|
||||
raise RepositoryError("Ungültiger Eigentümertyp.")
|
||||
if custody_type not in ASSET_CUSTODY_TYPE_LABELS:
|
||||
raise RepositoryError("Ungültiger Gewahrsamstyp.")
|
||||
if owner_type == "member":
|
||||
if not owner_member_id:
|
||||
raise RepositoryError("Bei Mitgliedseigentum muss ein Mitglied ausgewählt werden.")
|
||||
self.get_member(owner_member_id)
|
||||
elif owner_member_id:
|
||||
raise RepositoryError("Eine Eigentümer-Mitglieds-ID ist nur bei Mitgliedseigentum erlaubt.")
|
||||
if owner_type == "external" and not owner_name.strip():
|
||||
raise RepositoryError("Bei externem Eigentum ist ein Eigentümername erforderlich.")
|
||||
if custody_type == "member":
|
||||
if not holder_member_id:
|
||||
raise RepositoryError("Gewahrsam beim Mitglied benötigt eine Mitgliedszuordnung.")
|
||||
self.get_member(holder_member_id)
|
||||
elif holder_member_id:
|
||||
raise RepositoryError("Eine Halter-Mitglieds-ID ist nur bei Gewahrsam eines Mitglieds erlaubt.")
|
||||
|
||||
def create_manual_claim(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -731,6 +878,88 @@ class MemberRepository:
|
||||
)
|
||||
return item
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
*,
|
||||
title: str,
|
||||
due_date: str,
|
||||
base_amount: str,
|
||||
description: str,
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
if not title.strip():
|
||||
raise RepositoryError("Ein Forderungstitel ist erforderlich.")
|
||||
try:
|
||||
normalized_due_date = normalize_date_input(due_date, "Fälligkeitsdatum")
|
||||
selected_base_amount = decimal_value(base_amount, "Grundbetrag")
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_due_date:
|
||||
raise RepositoryError("Ein Fälligkeitsdatum ist erforderlich.")
|
||||
if selected_base_amount == 0:
|
||||
raise RepositoryError("Der Grundbetrag darf nicht null sein.")
|
||||
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
raise RepositoryError("Eine stornierte Forderung kann nicht bearbeitet werden.")
|
||||
items = materialize_claim_items(claim)
|
||||
base_item = next((item for item in items if str(item.get("type", "")) == "base"), None)
|
||||
if base_item is None:
|
||||
raise RepositoryError("Die Forderung hat keine bearbeitbare Grundposition.")
|
||||
|
||||
other_total = sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in items if item is not base_item),
|
||||
Decimal("0"),
|
||||
)
|
||||
new_total = selected_base_amount + other_total
|
||||
settled = allocated_total(data, claim_id)
|
||||
if new_total >= 0 and settled > new_total:
|
||||
raise RepositoryError(
|
||||
f"Der neue Gesamtbetrag darf nicht unter dem bereits zugeordneten Betrag "
|
||||
f"von {money_text(settled)} EUR liegen."
|
||||
)
|
||||
|
||||
old_values = {
|
||||
"title": str(claim.get("title", "")),
|
||||
"due_date": str(claim.get("due_date", "")),
|
||||
"base_amount": str(base_item.get("amount", "")),
|
||||
"description": str(base_item.get("description", "")),
|
||||
}
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
claim["title"] = title.strip()
|
||||
claim["due_date"] = normalized_due_date
|
||||
claim["amount"] = money_text(new_total)
|
||||
base_item["description"] = description.strip() or title.strip()
|
||||
base_item["quantity"] = "1.00"
|
||||
base_item["unit_price"] = money_text(selected_base_amount)
|
||||
base_item["amount"] = money_text(selected_base_amount)
|
||||
calculation = claim.get("calculation")
|
||||
if not isinstance(calculation, dict):
|
||||
calculation = {}
|
||||
claim["calculation"] = calculation
|
||||
calculation["manual_override"] = {"at": now, "actor": actor_name}
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="claim_changed",
|
||||
summary=f"Forderung geändert: {claim['title']}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"claim_id": claim_id},
|
||||
data={
|
||||
"old": old_values,
|
||||
"new": {
|
||||
"title": claim["title"],
|
||||
"due_date": claim["due_date"],
|
||||
"base_amount": base_item["amount"],
|
||||
"description": base_item["description"],
|
||||
},
|
||||
},
|
||||
)
|
||||
return claim
|
||||
|
||||
def record_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -760,6 +989,11 @@ class MemberRepository:
|
||||
if gnucash_id:
|
||||
self._assert_gnucash_id_available(gnucash_id)
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
available_claim_balance = max(claim_balance(data, claim), Decimal("0"))
|
||||
if selected_allocation > available_claim_balance:
|
||||
raise RepositoryError(
|
||||
f"Die Forderung hat nur noch {money_text(available_claim_balance)} EUR offen."
|
||||
)
|
||||
payment = {
|
||||
"payment_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
@@ -787,8 +1021,86 @@ class MemberRepository:
|
||||
)
|
||||
return payment
|
||||
|
||||
def record_combined_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
*,
|
||||
payment_date: str,
|
||||
allocations: dict[str, str],
|
||||
reference: str = "",
|
||||
method: str = "bank_transfer",
|
||||
actor_name: str = "CCMA",
|
||||
) -> dict:
|
||||
"""Record one payment and atomically allocate it to multiple claims."""
|
||||
try:
|
||||
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||
except DateValidationError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||
if not allocations:
|
||||
raise RepositoryError("Mindestens eine Zuordnung ist erforderlich.")
|
||||
|
||||
data = self.get_contributions(member_id)
|
||||
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||
selected_allocations: dict[str, Decimal] = {}
|
||||
for claim_id, raw_amount in allocations.items():
|
||||
claim = claims_by_id.get(claim_id)
|
||||
if claim is None:
|
||||
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.")
|
||||
try:
|
||||
selected_amount = decimal_value(raw_amount, "Zuordnung")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
available = max(claim_balance(data, claim), Decimal("0"))
|
||||
if selected_amount <= 0 or selected_amount > available:
|
||||
raise RepositoryError(
|
||||
f"{claim.get('title', 'Forderung')} hat nur "
|
||||
f"{money_text(available)} EUR offen."
|
||||
)
|
||||
selected_allocations[claim_id] = selected_amount
|
||||
|
||||
total = sum(selected_allocations.values(), Decimal("0"))
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
payment = {
|
||||
"payment_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
"amount": money_text(total),
|
||||
"method": method.strip() or "bank_transfer",
|
||||
"gnucash_transaction_id": "",
|
||||
"reference": reference.strip(),
|
||||
"created_at": now,
|
||||
}
|
||||
for claim_id, amount in selected_allocations.items():
|
||||
data.allocations.append(
|
||||
{
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment["payment_id"],
|
||||
"claim_id": claim_id,
|
||||
"amount": money_text(amount),
|
||||
}
|
||||
)
|
||||
data.payments.append(payment)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_recorded",
|
||||
summary=f"Zahlung eingegangen: {payment['amount']} EUR",
|
||||
actor_type="system" if method == "dummy" else "user",
|
||||
actor_name=actor_name,
|
||||
references={"payment_id": str(payment["payment_id"])},
|
||||
data={
|
||||
"allocated_amount": payment["amount"],
|
||||
"claim_ids": list(selected_allocations),
|
||||
"method": payment["method"],
|
||||
},
|
||||
)
|
||||
return payment
|
||||
|
||||
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
||||
data, _claim = self.get_claim(member_id, claim_id)
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
@@ -802,6 +1114,11 @@ class MemberRepository:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected_amount <= 0 or selected_amount > available:
|
||||
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
|
||||
available_claim_balance = max(claim_balance(data, claim), Decimal("0"))
|
||||
if selected_amount > available_claim_balance:
|
||||
raise RepositoryError(
|
||||
f"Die Forderung hat nur noch {money_text(available_claim_balance)} EUR offen."
|
||||
)
|
||||
allocation = {
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment_id,
|
||||
@@ -818,6 +1135,140 @@ class MemberRepository:
|
||||
)
|
||||
return allocation
|
||||
|
||||
def update_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
payment_id: str,
|
||||
*,
|
||||
payment_date: str,
|
||||
amount: str,
|
||||
allocations: dict[str, str],
|
||||
gnucash_transaction_id: str = "",
|
||||
reference: str = "",
|
||||
) -> dict:
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
try:
|
||||
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||
selected_amount = decimal_value(amount)
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||
|
||||
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
|
||||
old_by_claim: dict[str, list[dict]] = {}
|
||||
for allocation in old_allocations:
|
||||
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
|
||||
|
||||
selected_allocations: dict[str, Decimal] = {}
|
||||
for claim_id, raw_amount in allocations.items():
|
||||
if claim_id not in claims_by_id:
|
||||
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
|
||||
try:
|
||||
allocation_amount = decimal_value(raw_amount, "Zuordnung")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if allocation_amount < 0:
|
||||
raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
|
||||
if allocation_amount == 0:
|
||||
continue
|
||||
claim = claims_by_id[claim_id]
|
||||
currently_allocated = sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in old_by_claim.get(claim_id, [])),
|
||||
Decimal("0"),
|
||||
)
|
||||
available_claim_balance = max(
|
||||
claim_balance(data, claim) + currently_allocated,
|
||||
Decimal("0"),
|
||||
)
|
||||
if allocation_amount > available_claim_balance:
|
||||
raise RepositoryError(
|
||||
f"{claim.get('title', 'Forderung')} hat nur "
|
||||
f"{money_text(available_claim_balance)} EUR offen."
|
||||
)
|
||||
selected_allocations[claim_id] = allocation_amount
|
||||
|
||||
allocated_sum = sum(selected_allocations.values(), Decimal("0"))
|
||||
if allocated_sum > selected_amount:
|
||||
raise RepositoryError(
|
||||
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
|
||||
f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
|
||||
)
|
||||
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id:
|
||||
self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
|
||||
payment.update(
|
||||
{
|
||||
"date": normalized_date,
|
||||
"amount": money_text(selected_amount),
|
||||
"gnucash_transaction_id": gnucash_id,
|
||||
"reference": reference.strip(),
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
|
||||
retained_allocations = [
|
||||
item for item in data.allocations if str(item.get("payment_id", "")) != payment_id
|
||||
]
|
||||
new_allocations = []
|
||||
for claim_id, allocation_amount in selected_allocations.items():
|
||||
prior = old_by_claim.get(claim_id, [])
|
||||
new_allocations.append(
|
||||
{
|
||||
"allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
|
||||
"payment_id": payment_id,
|
||||
"claim_id": claim_id,
|
||||
"amount": money_text(allocation_amount),
|
||||
}
|
||||
)
|
||||
data.allocations = retained_allocations + new_allocations
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_changed",
|
||||
summary=f"Zahlung geändert: {payment['amount']} EUR",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"payment_id": payment_id},
|
||||
data={
|
||||
"allocated_amount": money_text(allocated_sum),
|
||||
"unallocated_amount": money_text(selected_amount - allocated_sum),
|
||||
},
|
||||
)
|
||||
return payment
|
||||
|
||||
def delete_payment(self, member_id: str, payment_id: str) -> None:
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
data.payments = [item for item in data.payments if str(item.get("payment_id", "")) != payment_id]
|
||||
data.allocations = [
|
||||
item for item in data.allocations if str(item.get("payment_id", "")) != payment_id
|
||||
]
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_deleted",
|
||||
summary=f"Zahlung gelöscht: {payment.get('amount', '')} EUR",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"payment_id": payment_id},
|
||||
)
|
||||
|
||||
def record_credit(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -1127,7 +1578,9 @@ class MemberRepository:
|
||||
references={"claim_id": claim_id},
|
||||
)
|
||||
|
||||
def _assert_gnucash_id_available(self, transaction_id: str) -> None:
|
||||
def _assert_gnucash_id_available(
|
||||
self, transaction_id: str, *, exclude_payment_id: str | None = None
|
||||
) -> None:
|
||||
selected = transaction_id.casefold()
|
||||
for member in self.list_members():
|
||||
try:
|
||||
@@ -1135,7 +1588,9 @@ class MemberRepository:
|
||||
except RepositoryError:
|
||||
continue
|
||||
if any(
|
||||
str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments
|
||||
str(payment.get("payment_id", "")) != exclude_payment_id
|
||||
and str(payment.get("gnucash_transaction_id", "")).casefold() == selected
|
||||
for payment in payments
|
||||
):
|
||||
raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}")
|
||||
|
||||
@@ -1286,15 +1741,30 @@ class MemberRepository:
|
||||
if mode not in {"automatic", "manual"}:
|
||||
mode = "automatic"
|
||||
pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))
|
||||
allocation_strategy = str(policy.get("allocation_strategy", "next_free"))
|
||||
if allocation_strategy not in {"next_free", "max_plus_one"}:
|
||||
allocation_strategy = "next_free"
|
||||
validate_member_number_pattern(pattern)
|
||||
return {"mode": mode, "pattern": pattern}
|
||||
return {
|
||||
"mode": mode,
|
||||
"pattern": pattern,
|
||||
"allocation_strategy": allocation_strategy,
|
||||
}
|
||||
|
||||
def save_member_number_policy(self, *, mode: str, pattern: str) -> None:
|
||||
def save_member_number_policy(
|
||||
self, *, mode: str, pattern: str, allocation_strategy: str = "next_free"
|
||||
) -> None:
|
||||
if mode not in {"automatic", "manual"}:
|
||||
raise RepositoryError("Ungültiger Mitgliedsnummernmodus.")
|
||||
if allocation_strategy not in {"next_free", "max_plus_one"}:
|
||||
raise RepositoryError("Ungültige Vergabestrategie für Mitgliedsnummern.")
|
||||
validate_member_number_pattern(pattern)
|
||||
config = read_json(self.root / "repository.json")
|
||||
config["member_number_policy"] = {"mode": mode, "pattern": pattern.strip()}
|
||||
config["member_number_policy"] = {
|
||||
"mode": mode,
|
||||
"pattern": pattern.strip(),
|
||||
"allocation_strategy": allocation_strategy,
|
||||
}
|
||||
config.setdefault("member_number_sequences", {})
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
|
||||
@@ -1310,11 +1780,15 @@ class MemberRepository:
|
||||
config["organization"] = organization
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
|
||||
def preview_member_number(self, pattern: str | None = None) -> str:
|
||||
selected_pattern = pattern or self.get_member_number_policy()["pattern"]
|
||||
def preview_member_number(
|
||||
self, pattern: str | None = None, allocation_strategy: str | None = None
|
||||
) -> str:
|
||||
policy = self.get_member_number_policy()
|
||||
selected_pattern = pattern or policy["pattern"]
|
||||
selected_strategy = allocation_strategy or policy["allocation_strategy"]
|
||||
validate_member_number_pattern(selected_pattern)
|
||||
config = read_json(self.root / "repository.json")
|
||||
return self._next_available_member_number(config, selected_pattern)[0]
|
||||
return self._next_available_member_number(config, selected_pattern, selected_strategy)[0]
|
||||
|
||||
def _member_directories(self) -> Iterable[Path]:
|
||||
if not self.members_root.exists():
|
||||
@@ -1340,9 +1814,11 @@ class MemberRepository:
|
||||
raise RepositoryError("Ungültige Asset-ID.")
|
||||
return self.assets_root / asset_id
|
||||
|
||||
def _allocate_member_number(self, pattern: str) -> str:
|
||||
def _allocate_member_number(self, pattern: str, allocation_strategy: str) -> str:
|
||||
config = read_json(self.root / "repository.json")
|
||||
member_number, next_value = self._next_available_member_number(config, pattern)
|
||||
member_number, next_value = self._next_available_member_number(
|
||||
config, pattern, allocation_strategy
|
||||
)
|
||||
sequences = config.get("member_number_sequences")
|
||||
if not isinstance(sequences, dict):
|
||||
sequences = {}
|
||||
@@ -1351,15 +1827,20 @@ class MemberRepository:
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
return member_number
|
||||
|
||||
def _next_available_member_number(self, config: dict, pattern: str) -> tuple[str, int]:
|
||||
sequences = config.get("member_number_sequences")
|
||||
if not isinstance(sequences, dict):
|
||||
sequences = {}
|
||||
try:
|
||||
number = max(1, int(sequences.get(pattern, 1)))
|
||||
except (TypeError, ValueError):
|
||||
number = 1
|
||||
def _next_available_member_number(
|
||||
self, config: dict, pattern: str, allocation_strategy: str
|
||||
) -> tuple[str, int]:
|
||||
if allocation_strategy not in {"next_free", "max_plus_one"}:
|
||||
raise RepositoryError("Ungültige Vergabestrategie für Mitgliedsnummern.")
|
||||
existing = {member.member_number.casefold() for member in self.list_members() if member.member_number}
|
||||
number = 1
|
||||
if allocation_strategy == "max_plus_one":
|
||||
used_values = (
|
||||
value
|
||||
for member_number in existing
|
||||
if (value := _number_from_formatted_member_number(pattern, member_number)) is not None
|
||||
)
|
||||
number = max(used_values, default=0) + 1
|
||||
for _attempt in range(1_000_000):
|
||||
candidate = format_member_number(pattern, number)
|
||||
number += 1
|
||||
@@ -1367,6 +1848,33 @@ class MemberRepository:
|
||||
return candidate, number
|
||||
raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.")
|
||||
|
||||
@contextmanager
|
||||
def _member_number_lock(self):
|
||||
lock_path = self.root / ".member-number.lock"
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
if not handle.read(1):
|
||||
handle.write("\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if os.name == "nt":
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
def _assert_member_number_available(
|
||||
self,
|
||||
member_number: str,
|
||||
@@ -1390,6 +1898,8 @@ class MemberRepository:
|
||||
"email": "E-Mail-Adresse",
|
||||
"phone": "Telefonnummer",
|
||||
"birth_date": "Geburtsdatum",
|
||||
"application_date": "Antragsdatum",
|
||||
"membership_ended_at": "Austrittsdatum",
|
||||
"status": "Status",
|
||||
"payment_frequency": "Zahlungsweise",
|
||||
"contribution_rule_id": "Beitragsregel",
|
||||
@@ -1431,6 +1941,14 @@ class MemberRepository:
|
||||
"serial_number": "Seriennummer",
|
||||
"status": "Status",
|
||||
"current_holder_member_id": "Zuordnung",
|
||||
"owner_type": "Eigentümertyp",
|
||||
"owner_member_id": "Eigentümer",
|
||||
"owner_name": "Externer Eigentümer",
|
||||
"custody_type": "Gewahrsam",
|
||||
"location": "Standort",
|
||||
"handed_over_at": "Übergabedatum",
|
||||
"condition": "Zustand",
|
||||
"estimated_value": "Wert",
|
||||
"deposit_amount_default": "Kaution",
|
||||
"notes": "Notiz",
|
||||
}
|
||||
@@ -1452,8 +1970,7 @@ def validate_iban(value: str) -> None:
|
||||
raise RepositoryError("Die IBAN hat kein gültiges Format.")
|
||||
rearranged = value[4:] + value[:4]
|
||||
numeric = "".join(
|
||||
str(ord(character) - 55) if character.isalpha() else character
|
||||
for character in rearranged
|
||||
str(ord(character) - 55) if character.isalpha() else character for character in rearranged
|
||||
)
|
||||
if int(numeric) % 97 != 1:
|
||||
raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.")
|
||||
@@ -1516,6 +2033,26 @@ def validate_member_number_pattern(pattern: str) -> None:
|
||||
raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc
|
||||
|
||||
|
||||
def _number_from_formatted_member_number(pattern: str, member_number: str) -> int | None:
|
||||
parts: list[str] = []
|
||||
number_groups = 0
|
||||
for literal, field_name, _format_spec, _conversion in Formatter().parse(pattern.strip()):
|
||||
parts.append(re.escape(literal))
|
||||
if field_name == "year":
|
||||
parts.append(re.escape(str(date.today().year)))
|
||||
elif field_name == "number":
|
||||
parts.append(r"(\d+)")
|
||||
number_groups += 1
|
||||
match = re.fullmatch("".join(parts), member_number, flags=re.IGNORECASE)
|
||||
if not match or not number_groups:
|
||||
return None
|
||||
values = [int(value) for value in match.groups()]
|
||||
if any(value != values[0] for value in values[1:]):
|
||||
return None
|
||||
value = values[0]
|
||||
return value if format_member_number(pattern, value).casefold() == member_number.casefold() else None
|
||||
|
||||
|
||||
def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str:
|
||||
try:
|
||||
value = pattern.strip().format(number=number, year=year or date.today().year)
|
||||
|
||||
@@ -115,6 +115,10 @@ class AssetTab(ttk.Frame):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
for row, (label, key) in enumerate(fields):
|
||||
self.variables[key] = tk.StringVar()
|
||||
@@ -151,15 +155,22 @@ class AssetTab(ttk.Frame):
|
||||
)
|
||||
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
|
||||
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
|
||||
self.owner_var = tk.StringVar()
|
||||
ttk.Label(data_tab, text="Eigentümer").grid(
|
||||
row=holder_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
self.owner_label = ttk.Label(data_tab, textvariable=self.owner_var, style="TimelineHeader.TLabel")
|
||||
self.owner_label.grid(row=holder_row + 2, column=1, sticky="w", pady=5)
|
||||
self.owner_label.bind("<Button-1>", lambda _event: self._open_owner_member(), add="+")
|
||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||
row=holder_row + 2,
|
||||
row=holder_row + 3,
|
||||
column=0,
|
||||
sticky="nw",
|
||||
pady=5,
|
||||
padx=(0, 12),
|
||||
)
|
||||
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
||||
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
|
||||
self.notes_text.grid(row=holder_row + 3, column=1, sticky="ew", pady=5)
|
||||
data_tab.columnconfigure(1, weight=1)
|
||||
self.issue_button = ttk.Button(
|
||||
action_buttons,
|
||||
@@ -276,13 +287,19 @@ class AssetTab(ttk.Frame):
|
||||
self.variables["inventory_number"].set(self.asset.inventory_number)
|
||||
self.variables["serial_number"].set(self.asset.serial_number)
|
||||
self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default)
|
||||
self.variables["location"].set(self.asset.location)
|
||||
self.variables["handed_over_at"].set(self.asset.handed_over_at)
|
||||
self.variables["condition"].set(self.asset.condition)
|
||||
self.variables["estimated_value"].set(self.asset.estimated_value)
|
||||
self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()))
|
||||
if self.notes_text is not None:
|
||||
self.notes_text.delete("1.0", "end")
|
||||
self.notes_text.insert("1.0", self.asset.notes)
|
||||
self.holder_var.set(holder)
|
||||
self.owner_var.set(self._owner_label())
|
||||
issued = bool(self.asset.current_holder_member_id)
|
||||
self.holder_label.configure(cursor="hand2" if issued else "")
|
||||
self.owner_label.configure(cursor="hand2" if self.asset.owner_type == "member" else "")
|
||||
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
|
||||
self.return_button.configure(state="normal" if issued else "disabled")
|
||||
self.status_box.configure(state="disabled" if issued else "readonly")
|
||||
@@ -300,6 +317,21 @@ class AssetTab(ttk.Frame):
|
||||
return self.asset.current_holder_member_id
|
||||
return f"{member.member_number or member.member_id} · {member.display_name}"
|
||||
|
||||
def _owner_label(self) -> str:
|
||||
if self.asset.owner_type == "club":
|
||||
return "Verein"
|
||||
if self.asset.owner_type == "external":
|
||||
return self.asset.owner_name or "Extern"
|
||||
try:
|
||||
member = self.repository.get_member(self.asset.owner_member_id)
|
||||
except RepositoryError:
|
||||
return self.asset.owner_member_id or "—"
|
||||
return f"{member.member_number or member.member_id} · {member.display_name}"
|
||||
|
||||
def _open_owner_member(self) -> None:
|
||||
if self.asset.owner_type == "member" and self.asset.owner_member_id:
|
||||
self.on_open_member(self.asset.owner_member_id)
|
||||
|
||||
def _refresh_events(self) -> None:
|
||||
self.timeline.delete(*self.timeline.get_children())
|
||||
try:
|
||||
@@ -352,6 +384,10 @@ class AssetTab(ttk.Frame):
|
||||
self.asset.inventory_number = self.variables["inventory_number"].get().strip()
|
||||
self.asset.serial_number = self.variables["serial_number"].get().strip()
|
||||
self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else ""
|
||||
self.asset.location = self.variables["location"].get().strip()
|
||||
self.asset.handed_over_at = self.variables["handed_over_at"].get().strip()
|
||||
self.asset.condition = self.variables["condition"].get().strip()
|
||||
self.asset.estimated_value = self.variables["estimated_value"].get().strip()
|
||||
if not self.asset.current_holder_member_id:
|
||||
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip()
|
||||
self.asset.status = _asset_status_key(self.variables["status"].get())
|
||||
|
||||
+211
-6
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.labels import (
|
||||
@@ -88,8 +89,10 @@ class ClaimTab(ttk.Frame):
|
||||
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.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
|
||||
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
|
||||
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
||||
self.cancel_button.grid(row=0, column=1, sticky="e")
|
||||
self.cancel_button.grid(row=0, column=2, sticky="e")
|
||||
|
||||
def _build_ledger(self) -> None:
|
||||
ledger = ttk.Frame(self, padding=12)
|
||||
@@ -154,6 +157,13 @@ class ClaimTab(ttk.Frame):
|
||||
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
|
||||
)
|
||||
self.send_reminder_button.pack(side="right", padx=(8, 0))
|
||||
self.email_reminder_button = ttk.Button(
|
||||
buttons,
|
||||
text="E-Mail erzeugen & versandt",
|
||||
command=self._email_reminder,
|
||||
state="disabled",
|
||||
)
|
||||
self.email_reminder_button.pack(side="right", padx=(8, 0))
|
||||
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
||||
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
||||
side="right", padx=(0, 8)
|
||||
@@ -185,6 +195,7 @@ class ClaimTab(ttk.Frame):
|
||||
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.edit_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_ledger()
|
||||
|
||||
@@ -422,8 +433,22 @@ class ClaimTab(ttk.Frame):
|
||||
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.email_reminder_button.configure(state=state)
|
||||
self.discard_reminder_button.configure(state=state)
|
||||
|
||||
def _email_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if reminder:
|
||||
ReminderMailDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
str(reminder["reminder_id"]),
|
||||
str(reminder.get("name", "Mahnung")),
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _send_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
@@ -478,6 +503,16 @@ class ClaimTab(ttk.Frame):
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _edit_claim(self) -> None:
|
||||
ClaimEditDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
self.claim,
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
@@ -502,6 +537,68 @@ class _Dialog(tk.Toplevel):
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class ClaimEditDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, claim, on_saved):
|
||||
super().__init__(master, "Forderung bearbeiten", on_saved)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_id = claim_id
|
||||
items = claim_items(claim)
|
||||
base_item = next(
|
||||
(item for item in items if str(item.get("type", "")) == "base"),
|
||||
items[0] if items else {},
|
||||
)
|
||||
self.variables = {
|
||||
"title": tk.StringVar(value=str(claim.get("title", ""))),
|
||||
"due_date": tk.StringVar(
|
||||
value=format_date_for_display(str(claim.get("due_date", "")))
|
||||
),
|
||||
"base_amount": tk.StringVar(
|
||||
value=str(base_item.get("amount", claim.get("amount", "")))
|
||||
),
|
||||
"description": tk.StringVar(value=str(base_item.get("description", ""))),
|
||||
}
|
||||
fields = (
|
||||
("Titel", "title"),
|
||||
(f"Fällig am ({date_input_hint()})", "due_date"),
|
||||
("Grundbetrag", "base_amount"),
|
||||
("Beschreibung der Grundposition", "description"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(
|
||||
row=row, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables[key], width=52).grid(
|
||||
row=row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
self.frame,
|
||||
text=(
|
||||
"Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, "
|
||||
"zum Beispiel Mahngebühren, bleiben erhalten."
|
||||
),
|
||||
style="Mono.TLabel",
|
||||
wraplength=520,
|
||||
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
self._buttons(len(fields) + 1, self._save)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.update_claim(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
title=self.variables["title"].get(),
|
||||
due_date=self.variables["due_date"].get(),
|
||||
base_amount=self.variables["base_amount"].get(),
|
||||
description=self.variables["description"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class ItemDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Forderungsposition hinzufügen", on_saved)
|
||||
@@ -634,7 +731,11 @@ class AllocatePaymentDialog(_Dialog):
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
data = repository.get_contributions(member_id)
|
||||
self.payment_by_label = {}
|
||||
for payment in data.payments:
|
||||
for payment in sorted(
|
||||
data.payments,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
||||
if available <= 0:
|
||||
@@ -655,10 +756,23 @@ class AllocatePaymentDialog(_Dialog):
|
||||
width=60,
|
||||
)
|
||||
combo.grid(row=0, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
if not self.payment_by_label:
|
||||
ttk.Label(
|
||||
self.frame,
|
||||
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
|
||||
style="Mono.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
|
||||
amount_row = 2
|
||||
else:
|
||||
amount_row = 1
|
||||
ttk.Label(self.frame, text="Betrag").grid(
|
||||
row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
|
||||
row=amount_row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
||||
self._buttons(2, self._save)
|
||||
self._buttons(amount_row + 1, self._save)
|
||||
|
||||
def _select(self, balance):
|
||||
_payment_id, available = self.payment_by_label[self.payment_var.get()]
|
||||
@@ -801,6 +915,97 @@ class ReminderDialog(_Dialog):
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class ReminderMailDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
repository,
|
||||
member_id,
|
||||
claim_id,
|
||||
reminder_id,
|
||||
reminder_name,
|
||||
on_saved,
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_id = claim_id
|
||||
self.reminder_id = reminder_id
|
||||
self.reminder_name = reminder_name
|
||||
self.on_saved = on_saved
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(
|
||||
value=str(organization.get("name") or "Verwaltung C3MA")
|
||||
)
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.title("Mahnung als E-Mail erzeugen")
|
||||
self.transient(master)
|
||||
self.resizable(True, False)
|
||||
self.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
|
||||
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
|
||||
),
|
||||
wraplength=620,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
|
||||
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
|
||||
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
|
||||
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
|
||||
self.signature = tk.Text(self, width=55, height=5, wrap="word")
|
||||
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
buttons = ttk.Frame(self, padding=14)
|
||||
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="EML speichern und als versandt buchen",
|
||||
style="Accent.TButton",
|
||||
command=self._generate,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Mahnung als Thunderbird-Entwurf speichern",
|
||||
defaultextension=".eml",
|
||||
initialfile=f"{self.reminder_name}.eml",
|
||||
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
try:
|
||||
result = generate_and_send_reminder_mail(
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
self.reminder_id,
|
||||
output_path=selected,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo(
|
||||
"Mahnung erzeugt",
|
||||
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
|
||||
parent=self,
|
||||
)
|
||||
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)
|
||||
|
||||
+141
-19
@@ -41,6 +41,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
"email",
|
||||
"phone",
|
||||
"birth_date",
|
||||
"application_date",
|
||||
"member_number",
|
||||
)
|
||||
}
|
||||
@@ -63,6 +64,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
(f"Antragsdatum ({date_input_hint()})", "application_date"),
|
||||
]
|
||||
if self.number_policy["mode"] == "manual":
|
||||
fields.append(("Mitgliedsnummer *", "member_number"))
|
||||
@@ -124,13 +126,31 @@ class NewAssetDialog(tk.Toplevel):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.on_created = on_created
|
||||
self.members = repository.list_members()
|
||||
self.member_labels = {
|
||||
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
|
||||
for member in self.members
|
||||
}
|
||||
self.title("Neues Asset")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
self.variables = {
|
||||
name: tk.StringVar()
|
||||
for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default")
|
||||
for name in (
|
||||
"label",
|
||||
"category",
|
||||
"inventory_number",
|
||||
"serial_number",
|
||||
"deposit_amount_default",
|
||||
"location",
|
||||
"handed_over_at",
|
||||
"condition",
|
||||
"estimated_value",
|
||||
"owner_name",
|
||||
)
|
||||
}
|
||||
self.owner_type_var = tk.StringVar(value="VEREIN")
|
||||
self.owner_member_var = tk.StringVar()
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.bind("<Return>", lambda _event: self._create())
|
||||
@@ -145,6 +165,10 @@ class NewAssetDialog(tk.Toplevel):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
self.entries: dict[str, ttk.Entry] = {}
|
||||
for row, (label, key) in enumerate(fields):
|
||||
@@ -152,17 +176,42 @@ class NewAssetDialog(tk.Toplevel):
|
||||
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||
self.entries[key] = entry
|
||||
owner_row = len(fields)
|
||||
ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_type_var,
|
||||
values=("VEREIN", "MITGLIED", "EXTERN"),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
|
||||
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_member_var,
|
||||
values=list(self.member_labels),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Externer Eigentümer").grid(
|
||||
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
|
||||
row=owner_row + 2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(frame, text="Interne Notiz").grid(
|
||||
row=len(fields),
|
||||
row=owner_row + 3,
|
||||
column=0,
|
||||
sticky="nw",
|
||||
pady=5,
|
||||
padx=(0, 12),
|
||||
)
|
||||
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
|
||||
self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
self.notes_text.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
buttons.grid(row=owner_row + 4, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
buttons,
|
||||
@@ -176,9 +225,15 @@ class NewAssetDialog(tk.Toplevel):
|
||||
_activate_modal_window(self, self.entries.get("label"))
|
||||
|
||||
def _create(self) -> None:
|
||||
owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[self.owner_type_var.get()]
|
||||
try:
|
||||
asset = self.repository.create_asset(
|
||||
**{key: variable.get() for key, variable in self.variables.items()},
|
||||
**{key: variable.get() for key, variable in self.variables.items() if key != "owner_name"},
|
||||
owner_type=owner_type,
|
||||
owner_member_id=(
|
||||
self.member_labels.get(self.owner_member_var.get(), "") if owner_type == "member" else ""
|
||||
),
|
||||
owner_name=self.variables["owner_name"].get() if owner_type == "external" else "",
|
||||
notes=self.notes_text.get("1.0", "end-1c"),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
@@ -201,6 +256,19 @@ class EditAssetDialog(tk.Toplevel):
|
||||
self.asset_id = asset_id
|
||||
self.on_saved = on_saved
|
||||
self.asset = repository.get_asset(asset_id)
|
||||
self.members = repository.list_members()
|
||||
self.member_labels = {
|
||||
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
|
||||
for member in self.members
|
||||
}
|
||||
owner_member_label = next(
|
||||
(
|
||||
label
|
||||
for label, member_id in self.member_labels.items()
|
||||
if member_id == self.asset.owner_member_id
|
||||
),
|
||||
"",
|
||||
)
|
||||
self.title("Asset bearbeiten")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
@@ -211,7 +279,18 @@ class EditAssetDialog(tk.Toplevel):
|
||||
"serial_number": tk.StringVar(value=self.asset.serial_number),
|
||||
"deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default),
|
||||
"status": tk.StringVar(value=self.asset.status),
|
||||
"location": tk.StringVar(value=self.asset.location),
|
||||
"handed_over_at": tk.StringVar(value=self.asset.handed_over_at),
|
||||
"condition": tk.StringVar(value=self.asset.condition),
|
||||
"estimated_value": tk.StringVar(value=self.asset.estimated_value),
|
||||
"owner_name": tk.StringVar(value=self.asset.owner_name),
|
||||
}
|
||||
self.owner_type_var = tk.StringVar(
|
||||
value={"club": "VEREIN", "member": "MITGLIED", "external": "EXTERN"}.get(
|
||||
self.asset.owner_type, "VEREIN"
|
||||
)
|
||||
)
|
||||
self.owner_member_var = tk.StringVar(value=owner_member_label)
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.bind("<Return>", lambda _event: self._save())
|
||||
@@ -227,6 +306,10 @@ class EditAssetDialog(tk.Toplevel):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
self.entries: dict[str, ttk.Entry] = {}
|
||||
for row, (label, key) in enumerate(fields):
|
||||
@@ -235,12 +318,40 @@ class EditAssetDialog(tk.Toplevel):
|
||||
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||
self.entries[key] = entry
|
||||
ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
status_values = [value for key, value in (
|
||||
("available", "VERFUEGBAR"),
|
||||
("lost", "VERLOREN"),
|
||||
("retired", "AUSGEMUSTERT"),
|
||||
)]
|
||||
owner_row = len(fields)
|
||||
ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_type_var,
|
||||
values=("VEREIN", "MITGLIED", "EXTERN"),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
|
||||
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_member_var,
|
||||
values=list(self.member_labels),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Externer Eigentümer").grid(
|
||||
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
|
||||
row=owner_row + 2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(frame, text="Status").grid(row=owner_row + 3, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
status_values = [
|
||||
value
|
||||
for key, value in (
|
||||
("available", "VERFUEGBAR"),
|
||||
("lost", "VERLOREN"),
|
||||
("retired", "AUSGEMUSTERT"),
|
||||
)
|
||||
]
|
||||
self.status_map = {
|
||||
"VERFUEGBAR": "available",
|
||||
"VERLOREN": "lost",
|
||||
@@ -260,8 +371,8 @@ class EditAssetDialog(tk.Toplevel):
|
||||
state="readonly" if not issued else "disabled",
|
||||
width=35,
|
||||
)
|
||||
self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
note_row = len(fields) + 1
|
||||
self.status_box.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
|
||||
note_row = owner_row + 4
|
||||
ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
|
||||
self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5)
|
||||
@@ -290,6 +401,21 @@ class EditAssetDialog(tk.Toplevel):
|
||||
self.asset.inventory_number = self.variables["inventory_number"].get()
|
||||
self.asset.serial_number = self.variables["serial_number"].get()
|
||||
self.asset.notes = self.notes_text.get("1.0", "end-1c")
|
||||
self.asset.location = self.variables["location"].get()
|
||||
self.asset.handed_over_at = self.variables["handed_over_at"].get()
|
||||
self.asset.condition = self.variables["condition"].get()
|
||||
self.asset.estimated_value = self.variables["estimated_value"].get()
|
||||
self.asset.owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[
|
||||
self.owner_type_var.get()
|
||||
]
|
||||
self.asset.owner_member_id = (
|
||||
self.member_labels.get(self.owner_member_var.get(), "")
|
||||
if self.asset.owner_type == "member"
|
||||
else ""
|
||||
)
|
||||
self.asset.owner_name = (
|
||||
self.variables["owner_name"].get() if self.asset.owner_type == "external" else ""
|
||||
)
|
||||
if not self.asset.current_holder_member_id:
|
||||
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get()
|
||||
self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status)
|
||||
@@ -394,9 +520,7 @@ class IssueAssetDialog(tk.Toplevel):
|
||||
self.member_tree.delete(*self.member_tree.get_children())
|
||||
query = self.search_var.get().strip().casefold()
|
||||
filtered = [
|
||||
member
|
||||
for member in self.members
|
||||
if not query or query in self._member_search_text(member)
|
||||
member for member in self.members if not query or query in self._member_search_text(member)
|
||||
]
|
||||
for member in filtered:
|
||||
self.member_tree.insert(
|
||||
@@ -599,9 +723,7 @@ class IntegrityWarningDialog(tk.Toplevel):
|
||||
text="\n".join(f"• {item}" for item in self.warnings),
|
||||
style="Warning.TLabel",
|
||||
justify="left",
|
||||
).grid(
|
||||
row=1, column=0, sticky="w", pady=(12, 0)
|
||||
)
|
||||
).grid(row=1, column=0, sticky="w", pady=(12, 0))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.grid(row=2, column=0, sticky="e", pady=(18, 0))
|
||||
ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
|
||||
@@ -14,6 +14,7 @@ from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, N
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.member_tab import MemberTab
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
from ccma.ui.sepa_dialog import SepaExportDialog
|
||||
from ccma.ui.theme import load_theme
|
||||
from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
|
||||
|
||||
@@ -187,6 +188,13 @@ class MainWindow(ttk.Frame):
|
||||
)
|
||||
assets_button.image = assets_icon
|
||||
assets_button.pack(side="left", padx=(0, 6))
|
||||
sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24)
|
||||
sepa_button = ttk.Button(
|
||||
actions, text="SEPA", image=sepa_icon, compound="top", width=14,
|
||||
command=self.open_sepa_export,
|
||||
)
|
||||
sepa_button.image = sepa_icon
|
||||
sepa_button.pack(side="left", padx=(0, 6))
|
||||
new_icon = self.icons.get("account-plus", 24)
|
||||
new_button = ttk.Button(
|
||||
actions,
|
||||
@@ -368,6 +376,7 @@ class MainWindow(ttk.Frame):
|
||||
tab = MembersTab(
|
||||
self.notebook,
|
||||
self.repository.list_members(),
|
||||
self.findings,
|
||||
self.open_member,
|
||||
lambda: self.tabs.close(key),
|
||||
)
|
||||
@@ -402,6 +411,9 @@ class MainWindow(ttk.Frame):
|
||||
icon_name="key-variant",
|
||||
)
|
||||
|
||||
def open_sepa_export(self) -> None:
|
||||
SepaExportDialog(self, self.repository)
|
||||
|
||||
def new_member(self) -> None:
|
||||
NewMemberDialog(self, self.repository, self._member_created)
|
||||
|
||||
@@ -502,7 +514,7 @@ class MainWindow(ttk.Frame):
|
||||
self.dashboard.update_data(self.repository.member_count(), self.findings)
|
||||
members_tab = self.tabs.tabs.get("members")
|
||||
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
|
||||
members_tab.refresh(self.repository.list_members())
|
||||
members_tab.refresh(self.repository.list_members(), self.findings)
|
||||
assets_tab = self.tabs.tabs.get("assets")
|
||||
if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists():
|
||||
assets_tab.refresh(self.repository.list_assets())
|
||||
|
||||
+151
-10
@@ -3,12 +3,19 @@ from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
claim_status,
|
||||
claim_total,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, Event
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||
@@ -16,6 +23,7 @@ from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.file_open import open_path
|
||||
from ccma.ui.labels import display_label, storage_key
|
||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||
from ccma.ui.payment_dialog import PaymentEditDialog
|
||||
from ccma.ui.scrolling import ScrollableFrame
|
||||
|
||||
CLAIM_TABLE_COLUMNS = (
|
||||
@@ -145,9 +153,11 @@ class MemberTab(ttk.Frame):
|
||||
command=self._save,
|
||||
).grid(row=0, column=0, sticky="e")
|
||||
contribution_tab = ttk.Frame(notebook, padding=16)
|
||||
payments_tab = ttk.Frame(notebook, padding=16)
|
||||
assets_tab = ttk.Frame(notebook, padding=16)
|
||||
documents_tab = ttk.Frame(notebook, padding=16)
|
||||
notebook.add(contribution_tab, text="Forderungen")
|
||||
notebook.add(payments_tab, text="Zahlungen")
|
||||
notebook.add(assets_tab, text="Assets")
|
||||
notebook.add(documents_tab, text="Dokumente")
|
||||
|
||||
@@ -160,8 +170,10 @@ class MemberTab(ttk.Frame):
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
(f"Antragsdatum ({date_input_hint()})", "application_date"),
|
||||
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
|
||||
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
|
||||
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
|
||||
]
|
||||
for row, (label, key) in enumerate(fields):
|
||||
variable = tk.StringVar()
|
||||
@@ -225,7 +237,18 @@ class MemberTab(ttk.Frame):
|
||||
(f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
|
||||
)
|
||||
banking_tab.columnconfigure(1, weight=1)
|
||||
for row, (label, key) in enumerate(banking_fields):
|
||||
self._add_variable("payment_frequency", tk.StringVar(), "banking")
|
||||
ttk.Label(banking_tab, text="Zahlweise").grid(
|
||||
row=0, column=0, sticky="w", padx=(0, 12), pady=5
|
||||
)
|
||||
ttk.Combobox(
|
||||
banking_tab,
|
||||
textvariable=self.variables["payment_frequency"],
|
||||
values=list(PAYMENT_FREQUENCY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=39,
|
||||
).grid(row=0, column=1, sticky="ew", pady=5)
|
||||
for row, (label, key) in enumerate(banking_fields, start=1):
|
||||
self._add_variable(key, tk.StringVar(), "banking")
|
||||
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
||||
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
|
||||
@@ -237,7 +260,7 @@ class MemberTab(ttk.Frame):
|
||||
text="SEPA-Lastschriftmandat ist aktiv",
|
||||
variable=self.variables["mandate_active"],
|
||||
style="Switch",
|
||||
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
|
||||
).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
|
||||
contribution_tab.columnconfigure(0, weight=1)
|
||||
contribution_tab.rowconfigure(1, weight=1)
|
||||
self.contribution_summary = tk.StringVar()
|
||||
@@ -256,6 +279,39 @@ class MemberTab(ttk.Frame):
|
||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
||||
|
||||
payments_tab.columnconfigure(0, weight=1)
|
||||
payments_tab.rowconfigure(1, weight=1)
|
||||
self.payment_summary = tk.StringVar()
|
||||
ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 10)
|
||||
)
|
||||
self.payments = ttk.Treeview(
|
||||
payments_tab,
|
||||
columns=("date", "amount", "allocated", "available", "reference"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("allocated", "Zugeordnet", 90),
|
||||
("available", "Frei", 90),
|
||||
("reference", "Referenz", 320),
|
||||
):
|
||||
self.payments.heading(key, text=title)
|
||||
self.payments.column(key, width=width, anchor="w")
|
||||
self.payments.grid(row=1, column=0, sticky="nsew")
|
||||
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
|
||||
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
|
||||
payment_actions = ttk.Frame(payments_tab)
|
||||
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
assets_tab.columnconfigure(0, weight=1)
|
||||
assets_tab.rowconfigure(1, weight=1)
|
||||
self.assets_summary = tk.StringVar()
|
||||
@@ -304,9 +360,7 @@ class MemberTab(ttk.Frame):
|
||||
style="Accent.TButton",
|
||||
command=self._create_document,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
|
||||
self.documents = ttk.Treeview(
|
||||
documents_tab,
|
||||
columns=("name", "type", "modified", "size"),
|
||||
@@ -422,8 +476,10 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
date_fields = {
|
||||
"birth_date",
|
||||
"application_date",
|
||||
"accepted_at",
|
||||
"membership_started_at",
|
||||
"membership_ended_at",
|
||||
"mandate_signed_at",
|
||||
"mandate_revoked_at",
|
||||
}
|
||||
@@ -431,6 +487,8 @@ class MemberTab(ttk.Frame):
|
||||
value = getattr(self.member, key)
|
||||
if key == "status":
|
||||
variable.set(display_label(STATUS_LABELS, str(value)))
|
||||
elif key == "payment_frequency":
|
||||
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
|
||||
else:
|
||||
variable.set(format_date_for_display(value) if key in date_fields else value)
|
||||
if self.notes_text is not None:
|
||||
@@ -456,10 +514,12 @@ class MemberTab(ttk.Frame):
|
||||
|
||||
def _refresh_contributions(self) -> None:
|
||||
self.claims.delete(*self.claims.get_children())
|
||||
self.payments.delete(*self.payments.get_children())
|
||||
try:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
self.contribution_summary.set(f"FEHLER: {exc}")
|
||||
self.payment_summary.set(f"FEHLER: {exc}")
|
||||
return
|
||||
claims = sorted(
|
||||
data.claims,
|
||||
@@ -481,7 +541,37 @@ class MemberTab(ttk.Frame):
|
||||
CLAIM_STATUS_LABELS.get(status, status.upper()),
|
||||
),
|
||||
)
|
||||
for payment in sorted(
|
||||
data.payments,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
amount = payment.get("amount", "0")
|
||||
allocated = payment_allocated_total(data, payment_id)
|
||||
available = max(Decimal(str(amount)) - allocated, Decimal("0"))
|
||||
self.payments.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=payment_id,
|
||||
values=(
|
||||
format_date_for_display(str(payment.get("date", ""))),
|
||||
f"{money_text(amount)} EUR",
|
||||
f"{money_text(allocated)} EUR",
|
||||
f"{money_text(available)} EUR",
|
||||
payment.get("reference", ""),
|
||||
),
|
||||
)
|
||||
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
|
||||
total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
|
||||
total_allocated = sum(
|
||||
(payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
|
||||
Decimal("0"),
|
||||
)
|
||||
self.payment_summary.set(
|
||||
f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
|
||||
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
|
||||
)
|
||||
|
||||
def _toggle_claim_sort(self, column: str) -> None:
|
||||
if self.claim_sort_column == column:
|
||||
@@ -507,6 +597,46 @@ class MemberTab(ttk.Frame):
|
||||
if selected and not selected[0].startswith("missing-id-"):
|
||||
self.on_open_claim(self.member_id, selected[0])
|
||||
|
||||
def _edit_selected_payment(self) -> None:
|
||||
selected = self.payments.selection()
|
||||
if not selected:
|
||||
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
PaymentEditDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
selected[0],
|
||||
self._payment_changed,
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)
|
||||
|
||||
def _delete_selected_payment(self) -> None:
|
||||
selected = self.payments.selection()
|
||||
if not selected:
|
||||
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
values = self.payments.item(selected[0], "values")
|
||||
label = f"{values[0]} · {values[1]}" if values else selected[0]
|
||||
if not messagebox.askyesno(
|
||||
"Zahlung wirklich löschen?",
|
||||
f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.delete_payment(self.member_id, selected[0])
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
|
||||
return
|
||||
self._payment_changed()
|
||||
|
||||
def _payment_changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _refresh_documents(self) -> None:
|
||||
self.documents.delete(*self.documents.get_children())
|
||||
self.document_paths.clear()
|
||||
@@ -536,8 +666,11 @@ class MemberTab(ttk.Frame):
|
||||
|
||||
def _refresh_assets(self) -> None:
|
||||
self.assets_tree.delete(*self.assets_tree.get_children())
|
||||
assets = self.repository.list_member_assets(self.member_id)
|
||||
self.assets_summary.set(f"{len(assets)} ausgegebene Assets")
|
||||
held_assets = self.repository.list_member_assets(self.member_id)
|
||||
owned_assets = self.repository.list_member_owned_assets(self.member_id)
|
||||
assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
|
||||
assets = list(assets_by_id.values())
|
||||
self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
|
||||
for asset in assets:
|
||||
self.assets_tree.insert(
|
||||
"",
|
||||
@@ -547,7 +680,13 @@ class MemberTab(ttk.Frame):
|
||||
asset.label,
|
||||
asset.category,
|
||||
asset.inventory_number,
|
||||
ASSET_STATUS_LABELS.get(asset.status, asset.status),
|
||||
(
|
||||
"EIGENTUM · DEM VEREIN ÜBERLASSEN"
|
||||
if asset.owner_member_id == self.member_id and asset.custody_type == "club"
|
||||
else "EIGENTUM"
|
||||
if asset.owner_member_id == self.member_id
|
||||
else ASSET_STATUS_LABELS.get(asset.status, asset.status)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -564,6 +703,8 @@ class MemberTab(ttk.Frame):
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||||
if key == "status":
|
||||
value = storage_key(STATUS_LABELS, value)
|
||||
elif key == "payment_frequency":
|
||||
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
|
||||
setattr(self.member, key, value)
|
||||
if self.notes_text is not None:
|
||||
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
|
||||
|
||||
@@ -47,6 +47,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
number_policy = repository.get_member_number_policy()
|
||||
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
||||
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
|
||||
self.number_strategy_var = tk.StringVar(value=number_policy["allocation_strategy"])
|
||||
self.number_preview_var = tk.StringVar()
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
if not isinstance(organization, dict):
|
||||
@@ -280,19 +281,38 @@ class OptionsDialog(tk.Toplevel):
|
||||
text="Platzhalter: {number}, {number:04d} und optional {year}",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=2, column=1, sticky="w", pady=(0, 12))
|
||||
ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6)
|
||||
ttk.Label(parent, text="Vergabestrategie").grid(
|
||||
row=3, column=0, sticky="nw", padx=(0, 12), pady=6
|
||||
)
|
||||
strategy_frame = ttk.Frame(parent)
|
||||
strategy_frame.grid(row=3, column=1, sticky="w", pady=6)
|
||||
ttk.Radiobutton(
|
||||
strategy_frame,
|
||||
text="Lücken füllen (nächste freie Nummer)",
|
||||
variable=self.number_strategy_var,
|
||||
value="next_free",
|
||||
command=self._update_number_preview,
|
||||
).pack(anchor="w")
|
||||
ttk.Radiobutton(
|
||||
strategy_frame,
|
||||
text="Immer Max++ (höchste Nummer + 1)",
|
||||
variable=self.number_strategy_var,
|
||||
value="max_plus_one",
|
||||
command=self._update_number_preview,
|
||||
).pack(anchor="w", pady=(4, 0))
|
||||
ttk.Label(parent, text="Nächste Nummer").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=6)
|
||||
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
|
||||
row=3, column=1, sticky="w", pady=6
|
||||
row=4, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern "
|
||||
"werden niemals doppelt erzeugt."
|
||||
"Die Strategie wird im Mitglieder-Store gespeichert. Die Vergabe ist gesperrt, "
|
||||
"solange ein neues Mitglied angelegt wird, damit keine Nummer doppelt entsteht."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=620,
|
||||
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0))
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(14, 0))
|
||||
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
|
||||
self._update_member_number_controls()
|
||||
|
||||
@@ -309,7 +329,9 @@ class OptionsDialog(tk.Toplevel):
|
||||
return
|
||||
pattern = self.number_pattern_var.get()
|
||||
try:
|
||||
preview = self.repository.preview_member_number(pattern)
|
||||
preview = self.repository.preview_member_number(
|
||||
pattern, self.number_strategy_var.get()
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
preview = f"UNGÜLTIG: {exc}"
|
||||
self.number_preview_var.set(preview)
|
||||
@@ -409,7 +431,11 @@ class OptionsDialog(tk.Toplevel):
|
||||
)
|
||||
try:
|
||||
self.config_obj.save()
|
||||
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
|
||||
self.repository.save_member_number_policy(
|
||||
mode=number_mode,
|
||||
pattern=number_pattern,
|
||||
allocation_strategy=self.number_strategy_var.get(),
|
||||
)
|
||||
self.repository.save_organization(
|
||||
{key: variable.get() for key, variable in self.organization_vars.items()}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
claim_balance,
|
||||
claim_status,
|
||||
decimal_value,
|
||||
money_text,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
class PaymentEditDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
payment_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.payment_id = payment_id
|
||||
self.on_saved = on_saved
|
||||
self.data = repository.get_contributions(member_id)
|
||||
self.payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in self.data.payments
|
||||
if str(payment.get("payment_id", "")) == payment_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if self.payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
self.allocations = self._current_allocations()
|
||||
self.claims_by_id = {
|
||||
str(claim.get("claim_id", "")): claim
|
||||
for claim in self.data.claims
|
||||
if str(claim.get("claim_id", ""))
|
||||
and (
|
||||
claim_status(self.data, claim) != "cancelled"
|
||||
or str(claim.get("claim_id", "")) in self.allocations
|
||||
)
|
||||
}
|
||||
self.capacities = self._claim_capacities()
|
||||
|
||||
self.title("Zahlung bearbeiten")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("880x560")
|
||||
self.minsize(720, 460)
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self._build_ui()
|
||||
self._refresh_claims()
|
||||
self.after_idle(self._activate)
|
||||
|
||||
def _current_allocations(self) -> dict[str, str]:
|
||||
totals: dict[str, Decimal] = {}
|
||||
for allocation in self.data.allocations:
|
||||
if str(allocation.get("payment_id", "")) != self.payment_id:
|
||||
continue
|
||||
claim_id = str(allocation.get("claim_id", ""))
|
||||
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
|
||||
allocation.get("amount", "0")
|
||||
)
|
||||
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
|
||||
|
||||
def _claim_capacities(self) -> dict[str, Decimal]:
|
||||
capacities = {}
|
||||
for claim_id, claim in self.claims_by_id.items():
|
||||
current = decimal_value(self.allocations.get(claim_id, "0"))
|
||||
capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0"))
|
||||
return capacities
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, weight=1)
|
||||
form = ttk.Frame(self, padding=16)
|
||||
form.grid(row=0, column=0, sticky="ew")
|
||||
form.columnconfigure(1, weight=1)
|
||||
self.variables = {
|
||||
"date": tk.StringVar(
|
||||
value=format_date_for_display(str(self.payment.get("date", "")))
|
||||
),
|
||||
"amount": tk.StringVar(value=str(self.payment.get("amount", ""))),
|
||||
"gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))),
|
||||
"reference": tk.StringVar(value=str(self.payment.get("reference", ""))),
|
||||
}
|
||||
fields = (
|
||||
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||
("Zahlungsbetrag", "amount"),
|
||||
("GnuCash-ID", "gnucash"),
|
||||
("Referenz", "reference"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
|
||||
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
|
||||
row=row, column=1, sticky="ew", pady=4
|
||||
)
|
||||
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
||||
|
||||
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
|
||||
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
allocation_frame.columnconfigure(0, weight=1)
|
||||
allocation_frame.rowconfigure(0, weight=1)
|
||||
self.claims = ttk.Treeview(
|
||||
allocation_frame,
|
||||
columns=("title", "due", "available", "allocated"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("title", "Forderung", 300),
|
||||
("due", "Fällig", 110),
|
||||
("available", "Maximal zuordenbar", 150),
|
||||
("allocated", "Aktuell zugeordnet", 150),
|
||||
):
|
||||
self.claims.heading(key, text=title)
|
||||
self.claims.column(key, width=width, anchor="w")
|
||||
self.claims.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.claims.configure(yscrollcommand=scrollbar.set)
|
||||
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
|
||||
|
||||
allocation_actions = ttk.Frame(allocation_frame)
|
||||
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
|
||||
self.allocation_var = tk.StringVar()
|
||||
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
|
||||
side="left", padx=(8, 8)
|
||||
)
|
||||
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
|
||||
side="left", padx=(8, 0)
|
||||
)
|
||||
self.total_var = tk.StringVar()
|
||||
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
|
||||
side="right"
|
||||
)
|
||||
|
||||
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||
buttons.grid(row=2, column=0, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Zahlung speichern",
|
||||
style="Accent.TButton",
|
||||
command=self._save,
|
||||
).pack(side="left")
|
||||
|
||||
def _activate(self) -> None:
|
||||
try:
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
return
|
||||
|
||||
def _refresh_claims(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
self.claims.delete(*self.claims.get_children())
|
||||
ordered = sorted(
|
||||
self.claims_by_id.items(),
|
||||
key=lambda item: (
|
||||
str(item[1].get("due_date", "")),
|
||||
str(item[1].get("title", "")).casefold(),
|
||||
),
|
||||
)
|
||||
for claim_id, claim in ordered:
|
||||
self.claims.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=claim_id,
|
||||
values=(
|
||||
claim.get("title", "Forderung"),
|
||||
format_date_for_display(str(claim.get("due_date", ""))),
|
||||
f"{money_text(self.capacities[claim_id])} EUR",
|
||||
f"{self.allocations.get(claim_id, '0.00')} EUR",
|
||||
),
|
||||
)
|
||||
if selected and self.claims.exists(selected[0]):
|
||||
self.claims.selection_set(selected[0])
|
||||
self._refresh_totals()
|
||||
|
||||
def _select_claim(self, _event=None) -> None:
|
||||
selected = self.claims.selection()
|
||||
if selected:
|
||||
self.allocation_var.set(self.allocations.get(selected[0], "0.00"))
|
||||
|
||||
def _set_allocation(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.allocation_var.get(), "Zuordnung")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self)
|
||||
return
|
||||
capacity = self.capacities[selected[0]]
|
||||
if amount < 0 or amount > capacity:
|
||||
messagebox.showerror(
|
||||
"Ungültige Zuordnung",
|
||||
f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
if amount:
|
||||
self.allocations[selected[0]] = money_text(amount)
|
||||
else:
|
||||
self.allocations.pop(selected[0], None)
|
||||
self._refresh_claims()
|
||||
|
||||
def _remove_allocation(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
||||
return
|
||||
self.allocations.pop(selected[0], None)
|
||||
self.allocation_var.set("0.00")
|
||||
self._refresh_claims()
|
||||
|
||||
def _refresh_totals(self) -> None:
|
||||
allocated = sum(
|
||||
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
|
||||
)
|
||||
try:
|
||||
payment_amount = decimal_value(self.variables["amount"].get())
|
||||
free = payment_amount - allocated
|
||||
self.total_var.set(
|
||||
f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
|
||||
)
|
||||
except (ValueError, InvalidOperation):
|
||||
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.update_payment(
|
||||
self.member_id,
|
||||
self.payment_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
allocations=self.allocations,
|
||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.dates import DateValidationError, normalize_date_input
|
||||
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
|
||||
from ccma.services.sepa_mail import generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def _next_weekday(selected: date) -> date:
|
||||
while selected.weekday() >= 5:
|
||||
selected += timedelta(days=1)
|
||||
return selected
|
||||
|
||||
|
||||
class SepaExportDialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, repository: MemberRepository):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.debits = []
|
||||
self.warnings: list[str] = []
|
||||
self.title("SEPA-Lastschriften")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("1000x560")
|
||||
self.minsize(760, 420)
|
||||
self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat())
|
||||
self.due_var = tk.StringVar(value=date.today().isoformat())
|
||||
self.collection_var = tk.StringVar(
|
||||
value=_next_weekday(date.today() + timedelta(days=2)).isoformat()
|
||||
)
|
||||
self.summary_var = tk.StringVar()
|
||||
self.warning_var = tk.StringVar()
|
||||
self._build_ui()
|
||||
self.refresh()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
filters = ttk.Frame(self, padding=12)
|
||||
filters.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
|
||||
ttk.Entry(filters, textvariable=self.from_var, width=14).grid(
|
||||
row=0, column=1, padx=(8, 20)
|
||||
)
|
||||
ttk.Label(filters, text="Fällig bis").grid(row=0, column=2)
|
||||
ttk.Entry(filters, textvariable=self.due_var, width=14).grid(
|
||||
row=0, column=3, padx=(8, 20)
|
||||
)
|
||||
ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4)
|
||||
ttk.Entry(filters, textvariable=self.collection_var, width=14).grid(
|
||||
row=0, column=5, padx=(8, 20)
|
||||
)
|
||||
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 8)
|
||||
)
|
||||
self.table = ttk.Treeview(
|
||||
self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings"
|
||||
)
|
||||
for key, label, width, anchor in (
|
||||
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
|
||||
("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"),
|
||||
("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"),
|
||||
):
|
||||
self.table.heading(key, text=label)
|
||||
self.table.column(key, width=width, anchor=anchor)
|
||||
self.table.grid(row=2, column=0, sticky="nsew", padx=12)
|
||||
footer = ttk.Frame(self, padding=12)
|
||||
footer.grid(row=3, column=0, sticky="ew")
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
|
||||
row=0, column=1, padx=(8, 0)
|
||||
)
|
||||
ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid(
|
||||
row=0, column=2, padx=(8, 0)
|
||||
)
|
||||
ttk.Button(
|
||||
footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml
|
||||
).grid(row=0, column=3, padx=(8, 0))
|
||||
ttk.Button(footer, text="Schließen", command=self.destroy).grid(
|
||||
row=0, column=4, padx=(8, 0)
|
||||
)
|
||||
|
||||
def _dates(self) -> tuple[date, date, date]:
|
||||
try:
|
||||
due_from = date.fromisoformat(
|
||||
normalize_date_input(self.from_var.get(), "Fällig ab")
|
||||
)
|
||||
due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis"))
|
||||
collection = date.fromisoformat(
|
||||
normalize_date_input(self.collection_var.get(), "Einzugsdatum")
|
||||
)
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if due_from > due:
|
||||
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
|
||||
if collection.weekday() >= 5:
|
||||
raise RepositoryError(
|
||||
"Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein."
|
||||
)
|
||||
return due_from, due, collection
|
||||
|
||||
def refresh(self) -> None:
|
||||
try:
|
||||
due_from, due, _collection = self._dates()
|
||||
self.debits, self.warnings = pending_direct_debits(
|
||||
self.repository, due_from=due_from, due_until=due
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for debit in self.debits:
|
||||
self.table.insert(
|
||||
"", "end",
|
||||
values=(debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
|
||||
)
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
self.warning_var.set(
|
||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
||||
)
|
||||
|
||||
def _prepare(self) -> tuple[date, date, date] | None:
|
||||
self.refresh()
|
||||
if not self.debits:
|
||||
messagebox.showinfo(
|
||||
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
|
||||
)
|
||||
return None
|
||||
if self.warnings and not messagebox.askyesno(
|
||||
"Unvollständige Mandate",
|
||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
return None
|
||||
return self._dates()
|
||||
|
||||
def _target(self, extension: str, title: str, collection: date) -> Path | None:
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self, title=title, defaultextension=extension,
|
||||
initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}",
|
||||
filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
return Path(selected) if selected else None
|
||||
|
||||
def _save_csv(self) -> None:
|
||||
dates = self._prepare()
|
||||
if not dates:
|
||||
return
|
||||
target = self._target(".csv", "CSV-Datei", dates[2])
|
||||
if target:
|
||||
write_csv(target, self.debits, collection_date=dates[2])
|
||||
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _save_xml(self) -> None:
|
||||
dates = self._prepare()
|
||||
if not dates:
|
||||
return
|
||||
target = self._target(".xml", "SEPA-XML", dates[2])
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
write_pain008(
|
||||
target, self.debits, collection_date=dates[2],
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _open_mail_export(self) -> None:
|
||||
dates = self._prepare()
|
||||
if dates:
|
||||
SepaMailExportDialog(self, self.repository, self.debits, dates[2])
|
||||
|
||||
|
||||
class SepaMailExportDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
debits: list,
|
||||
collection_date: date,
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.debits = debits
|
||||
self.collection_date = collection_date
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(
|
||||
value=str(organization.get("name") or "Verwaltung C3MA")
|
||||
)
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.title("SEPA-Info-Mails erzeugen")
|
||||
self.transient(master)
|
||||
self.resizable(True, False)
|
||||
self.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible "
|
||||
".eml-Entwürfe erzeugt."
|
||||
),
|
||||
wraplength=620,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
|
||||
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
|
||||
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
|
||||
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
|
||||
self.signature = tk.Text(self, width=55, height=5, wrap="word")
|
||||
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
buttons = ttk.Frame(self, padding=14)
|
||||
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Ordner wählen und Entwürfe erzeugen",
|
||||
style="Accent.TButton",
|
||||
command=self._generate,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
selected = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
||||
mustexist=True,
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
try:
|
||||
generated, warnings = generate_debit_mails(
|
||||
self.repository,
|
||||
self.debits,
|
||||
collection_date=self.collection_date,
|
||||
output_directory=selected,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
detail = (
|
||||
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
|
||||
"Mitgliederakten archiviert."
|
||||
)
|
||||
if warnings:
|
||||
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
||||
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
|
||||
self.destroy()
|
||||
@@ -16,8 +16,8 @@ from ccma.ui.sections import titled_frame
|
||||
|
||||
MEMBER_TABLE_COLUMNS = (
|
||||
("number", "Nummer", 110),
|
||||
("first_name", "Vorname", 160),
|
||||
("last_name", "Nachname", 180),
|
||||
("first_name", "Vorname", 160),
|
||||
("nickname", "Nickname", 160),
|
||||
("email", "E-Mail-Adresse", 270),
|
||||
("birth", "Geburtsdatum", 120),
|
||||
@@ -25,13 +25,21 @@ MEMBER_TABLE_COLUMNS = (
|
||||
)
|
||||
|
||||
STATUS_FILTER_ALL = "Alle"
|
||||
MEMBER_FINDING_COLORS = {
|
||||
"info": "#d9efff",
|
||||
"warning": "#fff3b0",
|
||||
"error": "#ffd0d0",
|
||||
}
|
||||
MEMBER_FINDING_PRIORITY = {"info": 1, "warning": 2, "error": 3}
|
||||
ASSET_FILTER_ALL = "Alle"
|
||||
ASSET_TABLE_COLUMNS = (
|
||||
("label", "Bezeichnung", 260),
|
||||
("category", "Kategorie", 140),
|
||||
("inventory_number", "Inventarnummer", 140),
|
||||
("status", "Status", 140),
|
||||
("holder", "Mitglied", 240),
|
||||
("owner", "Eigentümer", 240),
|
||||
("holder", "Gewahrsam", 240),
|
||||
("location", "Standort", 180),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,11 +81,22 @@ def _selected_status_filter(label: str) -> str:
|
||||
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
|
||||
|
||||
|
||||
def _member_finding_severities(findings: list[HousekeeperFinding]) -> dict[str, str]:
|
||||
severities: dict[str, str] = {}
|
||||
for finding in findings:
|
||||
if not finding.member_id or finding.severity not in MEMBER_FINDING_PRIORITY:
|
||||
continue
|
||||
current = severities.get(finding.member_id)
|
||||
if current is None or MEMBER_FINDING_PRIORITY[finding.severity] > MEMBER_FINDING_PRIORITY[current]:
|
||||
severities[finding.member_id] = finding.severity
|
||||
return severities
|
||||
|
||||
|
||||
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
|
||||
return titled_frame(parent, "// FILTER")
|
||||
|
||||
|
||||
def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
|
||||
def _asset_table_value(asset: Asset, column: str, holder_label: str, owner_label: str = "") -> str:
|
||||
if column == "label":
|
||||
return asset.label
|
||||
if column == "category":
|
||||
@@ -88,6 +107,10 @@ def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
|
||||
return ASSET_STATUS_LABELS.get(asset.status, asset.status)
|
||||
if column == "holder":
|
||||
return holder_label
|
||||
if column == "owner":
|
||||
return owner_label
|
||||
if column == "location":
|
||||
return asset.location
|
||||
return ""
|
||||
|
||||
|
||||
@@ -219,11 +242,13 @@ class MembersTab(ttk.Frame):
|
||||
self,
|
||||
master: tk.Misc,
|
||||
members: list[Member],
|
||||
findings: list[HousekeeperFinding],
|
||||
on_open: Callable[[str], None],
|
||||
on_close: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self.on_open = on_open
|
||||
self.on_close = on_close
|
||||
self._build_ui()
|
||||
@@ -244,12 +269,12 @@ class MembersTab(ttk.Frame):
|
||||
filter_row.grid(row=0, column=0, sticky="w")
|
||||
self.tree = ttk.Treeview(
|
||||
self,
|
||||
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||
columns=tuple(column for column, _title, _width in MEMBER_TABLE_COLUMNS),
|
||||
show="headings",
|
||||
)
|
||||
self.sort_column = "last_name"
|
||||
self.sort_descending = False
|
||||
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
|
||||
self.status_filter_var = tk.StringVar(value=MEMBERSHIP_STATUS_LABELS["active"])
|
||||
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||||
self.status_filter = ttk.Combobox(
|
||||
filter_row,
|
||||
@@ -263,13 +288,16 @@ class MembersTab(ttk.Frame):
|
||||
for key, title, width in MEMBER_TABLE_COLUMNS:
|
||||
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
|
||||
self.tree.column(key, width=width, anchor="w")
|
||||
for severity, color in MEMBER_FINDING_COLORS.items():
|
||||
self.tree.tag_configure(f"finding-{severity}", background=color, foreground="#202020")
|
||||
self.tree.grid(row=2, column=0, sticky="nsew")
|
||||
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
|
||||
self.tree.bind("<Return>", lambda _event: self._open_selected())
|
||||
self.refresh(self.members)
|
||||
self.refresh(self.members, self.findings)
|
||||
|
||||
def refresh(self, members: list[Member]) -> None:
|
||||
def refresh(self, members: list[Member], findings: list[HousekeeperFinding]) -> None:
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self._render_members()
|
||||
|
||||
def _render_members(self) -> None:
|
||||
@@ -277,20 +305,23 @@ class MembersTab(ttk.Frame):
|
||||
status_filter = _selected_status_filter(self.status_filter_var.get())
|
||||
filtered_members = _filter_members(self.members, status_filter)
|
||||
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
|
||||
finding_severities = _member_finding_severities(self.findings)
|
||||
if len(filtered_members) == len(self.members):
|
||||
self.count_var.set(f"{len(filtered_members)} Mitglieder")
|
||||
else:
|
||||
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
|
||||
self._update_tree_headings()
|
||||
for member in sorted_members:
|
||||
severity = finding_severities.get(member.member_id)
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=member.member_id,
|
||||
tags=(f"finding-{severity}",) if severity else (),
|
||||
values=(
|
||||
member.member_number,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.first_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
@@ -429,6 +460,7 @@ class AssetsTab(ttk.Frame):
|
||||
asset,
|
||||
self.sort_column,
|
||||
self.resolve_holder_label(asset.current_holder_member_id),
|
||||
self._owner_label(asset),
|
||||
).casefold(),
|
||||
reverse=self.sort_descending,
|
||||
)
|
||||
@@ -447,11 +479,20 @@ class AssetsTab(ttk.Frame):
|
||||
asset.category,
|
||||
asset.inventory_number,
|
||||
ASSET_STATUS_LABELS.get(asset.status, asset.status),
|
||||
self._owner_label(asset),
|
||||
self.resolve_holder_label(asset.current_holder_member_id),
|
||||
asset.location,
|
||||
),
|
||||
)
|
||||
self._update_actions()
|
||||
|
||||
def _owner_label(self, asset: Asset) -> str:
|
||||
if asset.owner_type == "club":
|
||||
return "Verein"
|
||||
if asset.owner_type == "member":
|
||||
return self.resolve_holder_label(asset.owner_member_id)
|
||||
return asset.owner_name or "Extern"
|
||||
|
||||
def _toggle_sort(self, column: str) -> None:
|
||||
if self.sort_column == column:
|
||||
self.sort_descending = not self.sort_descending
|
||||
|
||||
@@ -59,6 +59,49 @@ def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None:
|
||||
assert claim_balance(data, claim) == Decimal("90.00")
|
||||
|
||||
|
||||
def test_claim_base_data_can_be_edited(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
|
||||
repository.update_claim(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
title="Korrigierter Beitrag",
|
||||
due_date="30.06.2026",
|
||||
base_amount="75,00",
|
||||
description="Beitrag für sechs Monate",
|
||||
)
|
||||
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim["title"] == "Korrigierter Beitrag"
|
||||
assert claim["due_date"] == "2026-06-30"
|
||||
assert claim["amount"] == "75.00"
|
||||
assert claim["items"][0]["description"] == "Beitrag für sechs Monate"
|
||||
assert claim["items"][0]["amount"] == "75.00"
|
||||
assert claim["calculation"]["manual_override"]["actor"] == "Vorstand"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "claim_changed"
|
||||
|
||||
|
||||
def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="80.00",
|
||||
allocation_amount="80.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"):
|
||||
repository.update_claim(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
title="Zu klein",
|
||||
due_date="2026-06-30",
|
||||
base_amount="75.00",
|
||||
description="",
|
||||
)
|
||||
|
||||
|
||||
def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
@@ -104,6 +147,107 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "claim-2",
|
||||
"title": "Zweite Forderung",
|
||||
"amount": "50.00",
|
||||
"due_date": "2019-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
|
||||
payment = repository.record_combined_payment(
|
||||
member.member_id,
|
||||
payment_date="2019-12-31",
|
||||
allocations={"claim-1": "100.00", "claim-2": "50.00"},
|
||||
method="dummy",
|
||||
reference="Altbestand",
|
||||
)
|
||||
|
||||
changed = repository.get_contributions(member.member_id)
|
||||
assert payment["amount"] == "150.00"
|
||||
assert payment["method"] == "dummy"
|
||||
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
|
||||
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
|
||||
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
|
||||
|
||||
|
||||
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="12.50")
|
||||
|
||||
with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"):
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="22.50",
|
||||
allocation_amount="22.50",
|
||||
)
|
||||
|
||||
|
||||
def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="12.50")
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "admission-fee",
|
||||
"claim_key": "admission-fee",
|
||||
"title": "Aufnahmegebühr",
|
||||
"amount": "10.00",
|
||||
"due_date": "2026-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="22.50",
|
||||
allocation_amount="12.50",
|
||||
reference="Vorher",
|
||||
)
|
||||
|
||||
repository.update_payment(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
payment_date="22.06.2026",
|
||||
amount="22.50",
|
||||
allocations={"claim-1": "12.50", "admission-fee": "10.00"},
|
||||
reference="Korrigiert",
|
||||
)
|
||||
|
||||
changed = repository.get_contributions(member.member_id)
|
||||
assert changed.payments[0]["date"] == "2026-06-22"
|
||||
assert changed.payments[0]["reference"] == "Korrigiert"
|
||||
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50")
|
||||
assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"}
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
|
||||
|
||||
|
||||
def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
)
|
||||
|
||||
repository.delete_payment(member.member_id, payment["payment_id"])
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.payments == []
|
||||
assert data.allocations == []
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
|
||||
|
||||
|
||||
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
|
||||
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
|
||||
data = ContributionData(
|
||||
|
||||
@@ -58,6 +58,15 @@ def test_member_dates_must_be_chronological() -> None:
|
||||
today=date(2026, 6, 21),
|
||||
)
|
||||
|
||||
with pytest.raises(DateValidationError, match="Austrittsdatum"):
|
||||
validate_member_dates(
|
||||
birth_date="2000-01-01",
|
||||
accepted_at="2020-01-01",
|
||||
membership_started_at="2020-02-01",
|
||||
membership_ended_at="2020-01-31",
|
||||
today=date(2026, 6, 21),
|
||||
)
|
||||
|
||||
|
||||
def test_age_calculation_and_label() -> None:
|
||||
today = date(2026, 6, 21)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
from test_reminders import _overdue_claim_repository
|
||||
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
|
||||
|
||||
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
detail="Bitte Mitgliedsnummer angeben.",
|
||||
)
|
||||
export_path = tmp_path / "Zahlungserinnerung.eml"
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert message["To"] == "reminder@example.org"
|
||||
assert "Zahlungserinnerung" in message["Subject"]
|
||||
assert "100.00 Euro" in message.get_content()
|
||||
assert "DE98670505050038907751" in message.get_content()
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "sent"
|
||||
assert data.reminders[0]["payment_deadline"]
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
|
||||
assert generated.archive_path.parent.name == "Mahnungen"
|
||||
|
||||
|
||||
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
fee="5.00",
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
||||
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
||||
+133
-3
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +25,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
nickname="Enchantress",
|
||||
email="ada@example.org",
|
||||
birth_date="1990-12-10",
|
||||
application_date="30.07.2026",
|
||||
member_number="0042",
|
||||
)
|
||||
|
||||
@@ -37,10 +39,35 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
|
||||
assert raw["person"]["first_name"] == "Ada"
|
||||
assert raw["person"]["nickname"] == "Enchantress"
|
||||
assert raw["membership"]["application_date"] == "2026-07-30"
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["membership"]["ended_at"] == ""
|
||||
assert raw["contribution_profile"]["payment_frequency"] == "semiannual"
|
||||
assert raw["content_hash"]
|
||||
|
||||
|
||||
def test_existing_member_without_payment_frequency_defaults_to_semiannual(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Legacy", last_name="Member")
|
||||
member_path = repository.members_root / member.member_id / "member.json"
|
||||
raw = json.loads(member_path.read_text(encoding="utf-8"))
|
||||
raw["contribution_profile"].pop("payment_frequency")
|
||||
member_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert repository.get_member(member.member_id).payment_frequency == "semiannual"
|
||||
|
||||
|
||||
def test_repository_rejects_unknown_payment_frequency(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Invalid", last_name="Frequency")
|
||||
member.payment_frequency = "weekly"
|
||||
|
||||
with pytest.raises(RepositoryError, match="Ungültige Zahlweise"):
|
||||
repository.save_member(member)
|
||||
|
||||
|
||||
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -57,6 +84,22 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non
|
||||
assert [result.member_id for result in repository.search(query)] == [member.member_id]
|
||||
|
||||
|
||||
def test_past_membership_end_sets_status_to_ended(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Former", last_name="Member")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2020-01-01"
|
||||
member.membership_started_at = "2020-01-01"
|
||||
member.membership_ended_at = "2020-12-31"
|
||||
|
||||
repository.save_member(member)
|
||||
|
||||
loaded = repository.get_member(member.member_id)
|
||||
assert loaded.membership_ended_at == "2020-12-31"
|
||||
assert loaded.status == "ended"
|
||||
|
||||
|
||||
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -103,6 +146,18 @@ def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path)
|
||||
repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000")
|
||||
|
||||
|
||||
def test_existing_member_without_application_date_stays_empty(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Legacy", last_name="Member")
|
||||
path = repository.members_root / member.member_id / "member.json"
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw["membership"].pop("application_date")
|
||||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert repository.get_member(member.member_id).application_date == ""
|
||||
|
||||
|
||||
def test_repository_reports_empty_contributions_file(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -145,7 +200,6 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm
|
||||
first = repository.create_member(first_name="First", last_name="Member")
|
||||
second = repository.create_member(first_name="Second", last_name="Member")
|
||||
|
||||
|
||||
assert first.member_number == "CCMA-0001"
|
||||
assert second.member_number == "CCMA-0002"
|
||||
assert repository.preview_member_number() == "CCMA-0003"
|
||||
@@ -168,6 +222,43 @@ def test_custom_pattern_and_manual_mode(tmp_path) -> None:
|
||||
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
|
||||
|
||||
|
||||
def test_member_number_strategy_fills_gaps_or_uses_max_plus_one(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
repository.create_member(first_name="One", last_name="Member", member_number="MA-001")
|
||||
repository.create_member(first_name="Three", last_name="Member", member_number="MA-003")
|
||||
|
||||
repository.save_member_number_policy(
|
||||
mode="automatic",
|
||||
pattern="MA-{number:03d}",
|
||||
allocation_strategy="next_free",
|
||||
)
|
||||
assert repository.preview_member_number() == "MA-002"
|
||||
assert repository.get_member_number_policy()["allocation_strategy"] == "next_free"
|
||||
|
||||
repository.save_member_number_policy(
|
||||
mode="automatic",
|
||||
pattern="MA-{number:03d}",
|
||||
allocation_strategy="max_plus_one",
|
||||
)
|
||||
assert repository.preview_member_number() == "MA-004"
|
||||
created = repository.create_member(first_name="Four", last_name="Member")
|
||||
assert created.member_number == "MA-004"
|
||||
|
||||
|
||||
def test_concurrent_automatic_member_numbers_are_unique(tmp_path) -> None:
|
||||
MemberRepository(tmp_path).initialize()
|
||||
|
||||
def create(index: int) -> str:
|
||||
repository = MemberRepository(tmp_path)
|
||||
return repository.create_member(first_name=f"Member {index}", last_name="Concurrent").member_number
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
numbers = list(executor.map(create, range(16)))
|
||||
|
||||
assert len(numbers) == len(set(numbers)) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
|
||||
def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
|
||||
with pytest.raises(RepositoryError):
|
||||
@@ -286,6 +377,46 @@ def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None:
|
||||
assert repository.list_member_assets(member.member_id) == []
|
||||
|
||||
|
||||
def test_member_owned_asset_is_kept_in_central_inventory(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
owner = repository.create_member(first_name="Ada", last_name="Lovelace")
|
||||
|
||||
asset = repository.create_asset(
|
||||
label="3D-Drucker",
|
||||
owner_type="member",
|
||||
owner_member_id=owner.member_id,
|
||||
custody_type="club",
|
||||
location="Clubraum / Werkstatt",
|
||||
handed_over_at="2026-07-22",
|
||||
condition="gebraucht, funktionsfähig",
|
||||
estimated_value="450.00",
|
||||
)
|
||||
|
||||
loaded = repository.get_asset(asset.asset_id)
|
||||
assert loaded.owner_member_id == owner.member_id
|
||||
assert loaded.custody_type == "club"
|
||||
assert loaded.location == "Clubraum / Werkstatt"
|
||||
assert [item.asset_id for item in repository.list_member_owned_assets(owner.member_id)] == [
|
||||
asset.asset_id
|
||||
]
|
||||
assert repository.list_member_assets(owner.member_id) == []
|
||||
assert repository.list_assets()[0].asset_id == asset.asset_id
|
||||
assert repository.get_events(owner.member_id)[-1].event_type == "member_asset_registered"
|
||||
|
||||
|
||||
def test_member_ownership_requires_existing_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Mitglied"):
|
||||
repository.create_asset(
|
||||
label="Privater Beamer",
|
||||
owner_type="member",
|
||||
owner_member_id="missing-member",
|
||||
)
|
||||
|
||||
|
||||
def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -423,6 +554,5 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None:
|
||||
findings = Housekeeper(repository).run()
|
||||
|
||||
assert any(
|
||||
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id
|
||||
for finding in findings
|
||||
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -87,10 +88,52 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
|
||||
amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims}
|
||||
assert amounts["membership-fee:2026:first-half"] == "37.50"
|
||||
assert amounts["membership-fee:2026:second-half"] == "75.00"
|
||||
descriptions = {
|
||||
claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims
|
||||
}
|
||||
assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)")
|
||||
assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||
assert state["run_counter"] == 2
|
||||
assert state["last_completed_run"] == "2026-04-15:000002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frequency", "expected_suffixes", "expected_amount"),
|
||||
[
|
||||
("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"),
|
||||
("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"),
|
||||
("semiannual", ["first-half", "second-half"], "75.00"),
|
||||
("annual", ["annual"], "150.00"),
|
||||
],
|
||||
)
|
||||
def test_housekeeper_creates_claims_for_each_payment_frequency(
|
||||
tmp_path, frequency, expected_suffixes, expected_amount
|
||||
) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(
|
||||
first_name=frequency,
|
||||
last_name="Frequency",
|
||||
birth_date="1990-01-01",
|
||||
)
|
||||
member.status = "active"
|
||||
member.accepted_at = "2026-01-01"
|
||||
member.membership_started_at = "2026-01-01"
|
||||
member.payment_frequency = frequency
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 12, 31))
|
||||
|
||||
membership_claims = [
|
||||
claim
|
||||
for claim in repository.get_contributions(member.member_id).claims
|
||||
if claim.get("type") == "membership_fee"
|
||||
]
|
||||
assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes
|
||||
assert {claim["amount"] for claim in membership_claims} == {expected_amount}
|
||||
assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00")
|
||||
|
||||
|
||||
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -98,6 +141,7 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
@@ -121,6 +165,9 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
|
||||
"membership-fee:2026:annual",
|
||||
}
|
||||
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
|
||||
assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith(
|
||||
"(9 Monate)"
|
||||
)
|
||||
|
||||
|
||||
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
|
||||
@@ -130,6 +177,7 @@ def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_pat
|
||||
member.status = "active"
|
||||
member.accepted_at = "2021-04-15"
|
||||
member.membership_started_at = "2021-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
@@ -157,6 +205,7 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
@@ -169,6 +218,24 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
|
||||
}
|
||||
|
||||
|
||||
def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01")
|
||||
member.status = "resigned_end_of_year"
|
||||
member.accepted_at = "2024-01-01"
|
||||
member.membership_started_at = "2024-01-01"
|
||||
member.membership_ended_at = "2026-12-31"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
|
||||
assert "membership-fee:2026:annual" in claim_keys
|
||||
assert "membership-fee:2027:annual" not in claim_keys
|
||||
|
||||
|
||||
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
|
||||
from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.ui.sepa_dialog import _next_weekday
|
||||
|
||||
|
||||
def _repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42")
|
||||
member.status = "active"
|
||||
member.email = "ada@example.org"
|
||||
member.account_holder = "Ada Lovelace"
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.bic = "COBADEFFXXX"
|
||||
member.mandate_reference = "MANDAT-42"
|
||||
member.mandate_signed_at = "2025-01-10"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
{
|
||||
"claim_id": "future",
|
||||
"title": "Mitgliedsbeitrag 2027",
|
||||
"amount": "150.00",
|
||||
"due_date": "2027-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
],
|
||||
payments=[{"payment_id": "payment", "amount": "25.00"}],
|
||||
allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}],
|
||||
),
|
||||
)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_pending_debits_select_due_open_balance(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
assert warnings == []
|
||||
assert len(debits) == 1
|
||||
assert debits[0].member_id == member.member_id
|
||||
assert debits[0].amount == Decimal("125.00")
|
||||
assert debits[0].claim_ids == ("due",)
|
||||
|
||||
|
||||
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
member.accepted_at = "2025-01-01"
|
||||
member.membership_started_at = "2025-01-01"
|
||||
member.payment_frequency = "monthly"
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(member.member_id, ContributionData())
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 3, 31))
|
||||
debits, warnings = pending_direct_debits(
|
||||
repository,
|
||||
due_from=date(2026, 1, 1),
|
||||
due_until=date(2026, 3, 31),
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert len(debits) == 1
|
||||
assert debits[0].amount == Decimal("37.50")
|
||||
assert len(debits[0].claim_ids) == 3
|
||||
|
||||
|
||||
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
|
||||
debits, warnings = pending_direct_debits(
|
||||
repository,
|
||||
due_from=date(2026, 2, 1),
|
||||
due_until=date(2026, 12, 31),
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert debits == []
|
||||
|
||||
|
||||
def test_next_weekday_moves_weekend_to_monday():
|
||||
assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3)
|
||||
assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3)
|
||||
|
||||
|
||||
def test_sepa_text_normalizes_unsupported_characters():
|
||||
assert _safe("Sébastien O’Connor – Müller", 70) == "Sebastien O'Connor - Müller"
|
||||
|
||||
|
||||
def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
text = csv_text(debits, collection_date=date(2026, 8, 3))
|
||||
|
||||
assert "Einzugsdatum;Mitgliedsnummer" in text
|
||||
assert "125,00;EUR;MANDAT-42" in text
|
||||
|
||||
|
||||
def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
content = pain008_bytes(
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-TEST",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
root = ET.fromstring(content)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00"
|
||||
assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42"
|
||||
assert (
|
||||
root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns)
|
||||
== "DE98ZZZ09999999999"
|
||||
)
|
||||
assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None
|
||||
assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03"
|
||||
|
||||
|
||||
def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
root = ET.fromstring(
|
||||
pain008_bytes(
|
||||
[debit, debit],
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-BATCH-TEST",
|
||||
)
|
||||
)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1
|
||||
assert len(root.findall(".//p:PmtInf", ns)) == 1
|
||||
assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2
|
||||
assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2"
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00"
|
||||
|
||||
|
||||
def test_debit_mail_is_thunderbird_draft(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=date(2026, 8, 3),
|
||||
creditor_id="DE98ZZZ09999999999",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
message = BytesParser(policy=default).parsebytes(content)
|
||||
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft")
|
||||
assert "125.00 Euro" in message.get_content()
|
||||
assert "MANDAT-42" in message.get_content()
|
||||
|
||||
|
||||
def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
output_directory=tmp_path / "mail-export",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path.is_file()
|
||||
assert generated[0].archive_path.is_file()
|
||||
assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes()
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "sepa_notification_generated"
|
||||
assert event.references["document"].startswith("documents/SEPA/")
|
||||
@@ -131,6 +131,20 @@ def test_member_table_sort_uses_display_values() -> None:
|
||||
assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"]
|
||||
|
||||
|
||||
def test_member_finding_severity_uses_highest_level_per_member() -> None:
|
||||
from ccma.domain.models import HousekeeperFinding
|
||||
from ccma.ui.work_tabs import _member_finding_severities
|
||||
|
||||
findings = [
|
||||
HousekeeperFinding("info", "info", "Info", "", member_id="1"),
|
||||
HousekeeperFinding("warning", "warning", "Warning", "", member_id="1"),
|
||||
HousekeeperFinding("error", "error", "Error", "", member_id="2"),
|
||||
HousekeeperFinding("info", "global", "Global", ""),
|
||||
]
|
||||
|
||||
assert _member_finding_severities(findings) == {"1": "warning", "2": "error"}
|
||||
|
||||
|
||||
def test_asset_table_filter_and_sort_use_status_and_holder_label() -> None:
|
||||
from ccma.domain.models import Asset
|
||||
from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter
|
||||
|
||||
@@ -5,4 +5,4 @@ from ccma import __version__
|
||||
|
||||
def test_ui_version_matches_version_file() -> None:
|
||||
expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert __version__ == expected == "0.0.1-dev1"
|
||||
assert __version__ == expected
|
||||
|
||||
Reference in New Issue
Block a user