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