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
+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.")