mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
feat: extend inventory and administration workflows
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+362
-15
@@ -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.")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user