feat: extend inventory and administration workflows

This commit is contained in:
Marcel Peterkau
2026-07-22 22:26:02 +02:00
parent aeaaca1459
commit 070684d9bc
17 changed files with 1342 additions and 69 deletions
+6
View File
@@ -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:
+45
View File
@@ -27,6 +27,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",
@@ -77,6 +90,7 @@ class Member:
status: str = "application"
accepted_at: str = ""
membership_started_at: str = ""
membership_ended_at: str = ""
payment_frequency: str = "annual"
contribution_rule_id: str = "standard-2022"
honorary: bool = False
@@ -122,6 +136,7 @@ class Member:
"status": self.status,
"accepted_at": self.accepted_at,
"started_at": self.membership_started_at,
"ended_at": self.membership_ended_at,
"honorary": self.honorary,
},
"contribution_profile": {
@@ -165,6 +180,7 @@ class Member:
status=str(membership.get("status", "application")),
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")),
@@ -183,6 +199,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 +223,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 +248,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:
+32 -15
View File
@@ -18,6 +18,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 +30,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 +53,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:
@@ -96,7 +105,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 +121,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": {
+1
View File
@@ -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,
+3 -1
View File
@@ -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")),
+362 -15
View File
@@ -6,11 +6,13 @@ import shutil
import unicodedata
from collections.abc import Iterable
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,6 +23,8 @@ 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,
Asset,
@@ -134,9 +138,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():
@@ -167,13 +169,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 +201,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 +220,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))
@@ -325,6 +337,7 @@ class MemberRepository:
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
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 +346,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)
@@ -393,6 +409,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 +426,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 +439,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 +456,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 +473,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 +498,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 +520,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 +548,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 +576,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 +602,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 +840,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 +951,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,
@@ -788,7 +984,7 @@ class MemberRepository:
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 +998,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 +1019,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 +1462,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 +1472,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}")
@@ -1390,6 +1729,7 @@ class MemberRepository:
"email": "E-Mail-Adresse",
"phone": "Telefonnummer",
"birth_date": "Geburtsdatum",
"membership_ended_at": "Austrittsdatum",
"status": "Status",
"payment_frequency": "Zahlungsweise",
"contribution_rule_id": "Beitragsregel",
@@ -1431,6 +1771,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 +1800,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.")
+38 -2
View File
@@ -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())
+97 -5
View File
@@ -88,8 +88,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)
@@ -185,6 +187,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()
@@ -478,6 +481,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 +515,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 +709,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 +734,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()]
+139 -19
View File
@@ -124,13 +124,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 +163,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 +174,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 +223,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 +254,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 +277,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 +304,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 +316,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 +369,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 +399,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 +518,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 +721,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))
+131 -7
View File
@@ -3,10 +3,17 @@ 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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
@@ -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")
@@ -162,6 +172,7 @@ class MemberTab(ttk.Frame):
(f"Geburtsdatum ({date_input_hint()})", "birth_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()
@@ -256,6 +267,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 +348,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"),
@@ -424,6 +466,7 @@ class MemberTab(ttk.Frame):
"birth_date",
"accepted_at",
"membership_started_at",
"membership_ended_at",
"mandate_signed_at",
"mandate_revoked_at",
}
@@ -456,10 +499,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 +526,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 +582,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 +651,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 +665,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)
),
),
)
+263
View File
@@ -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()
+18 -2
View File
@@ -31,7 +31,9 @@ ASSET_TABLE_COLUMNS = (
("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140),
("status", "Status", 140),
("holder", "Mitglied", 240),
("owner", "Eigentümer", 240),
("holder", "Gewahrsam", 240),
("location", "Standort", 180),
)
@@ -77,7 +79,7 @@ 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 +90,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 ""
@@ -429,6 +435,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 +454,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