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
+114
View File
@@ -59,6 +59,49 @@ def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None:
assert claim_balance(data, claim) == Decimal("90.00")
def test_claim_base_data_can_be_edited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.update_claim(
member.member_id,
"claim-1",
title="Korrigierter Beitrag",
due_date="30.06.2026",
base_amount="75,00",
description="Beitrag für sechs Monate",
)
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim["title"] == "Korrigierter Beitrag"
assert claim["due_date"] == "2026-06-30"
assert claim["amount"] == "75.00"
assert claim["items"][0]["description"] == "Beitrag für sechs Monate"
assert claim["items"][0]["amount"] == "75.00"
assert claim["calculation"]["manual_override"]["actor"] == "Vorstand"
assert repository.get_events(member.member_id)[-1].event_type == "claim_changed"
def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="80.00",
allocation_amount="80.00",
)
with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"):
repository.update_claim(
member.member_id,
"claim-1",
title="Zu klein",
due_date="2026-06-30",
base_amount="75.00",
description="",
)
def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id)
@@ -104,6 +147,77 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
)
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"):
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="22.50",
)
def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "admission-fee",
"claim_key": "admission-fee",
"title": "Aufnahmegebühr",
"amount": "10.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="12.50",
reference="Vorher",
)
repository.update_payment(
member.member_id,
payment["payment_id"],
payment_date="22.06.2026",
amount="22.50",
allocations={"claim-1": "12.50", "admission-fee": "10.00"},
reference="Korrigiert",
)
changed = repository.get_contributions(member.member_id)
assert changed.payments[0]["date"] == "2026-06-22"
assert changed.payments[0]["reference"] == "Korrigiert"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50")
assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"}
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
)
repository.delete_payment(member.member_id, payment["payment_id"])
data = repository.get_contributions(member.member_id)
assert data.payments == []
assert data.allocations == []
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(
+9
View File
@@ -58,6 +58,15 @@ def test_member_dates_must_be_chronological() -> None:
today=date(2026, 6, 21),
)
with pytest.raises(DateValidationError, match="Austrittsdatum"):
validate_member_dates(
birth_date="2000-01-01",
accepted_at="2020-01-01",
membership_started_at="2020-02-01",
membership_ended_at="2020-01-31",
today=date(2026, 6, 21),
)
def test_age_calculation_and_label() -> None:
today = date(2026, 6, 21)
+58 -3
View File
@@ -38,6 +38,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
assert raw["person"]["first_name"] == "Ada"
assert raw["person"]["nickname"] == "Enchantress"
assert raw["schema_version"] == 1
assert raw["membership"]["ended_at"] == ""
assert raw["content_hash"]
@@ -57,6 +58,22 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non
assert [result.member_id for result in repository.search(query)] == [member.member_id]
def test_past_membership_end_sets_status_to_ended(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Former", last_name="Member")
member.status = "active"
member.accepted_at = "2020-01-01"
member.membership_started_at = "2020-01-01"
member.membership_ended_at = "2020-12-31"
repository.save_member(member)
loaded = repository.get_member(member.member_id)
assert loaded.membership_ended_at == "2020-12-31"
assert loaded.status == "ended"
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -145,7 +162,6 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm
first = repository.create_member(first_name="First", last_name="Member")
second = repository.create_member(first_name="Second", last_name="Member")
assert first.member_number == "CCMA-0001"
assert second.member_number == "CCMA-0002"
assert repository.preview_member_number() == "CCMA-0003"
@@ -286,6 +302,46 @@ def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None:
assert repository.list_member_assets(member.member_id) == []
def test_member_owned_asset_is_kept_in_central_inventory(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
owner = repository.create_member(first_name="Ada", last_name="Lovelace")
asset = repository.create_asset(
label="3D-Drucker",
owner_type="member",
owner_member_id=owner.member_id,
custody_type="club",
location="Clubraum / Werkstatt",
handed_over_at="2026-07-22",
condition="gebraucht, funktionsfähig",
estimated_value="450.00",
)
loaded = repository.get_asset(asset.asset_id)
assert loaded.owner_member_id == owner.member_id
assert loaded.custody_type == "club"
assert loaded.location == "Clubraum / Werkstatt"
assert [item.asset_id for item in repository.list_member_owned_assets(owner.member_id)] == [
asset.asset_id
]
assert repository.list_member_assets(owner.member_id) == []
assert repository.list_assets()[0].asset_id == asset.asset_id
assert repository.get_events(owner.member_id)[-1].event_type == "member_asset_registered"
def test_member_ownership_requires_existing_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Mitglied"):
repository.create_asset(
label="Privater Beamer",
owner_type="member",
owner_member_id="missing-member",
)
def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -423,6 +479,5 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None:
findings = Housekeeper(repository).run()
assert any(
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id
for finding in findings
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings
)
+25
View File
@@ -87,6 +87,11 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims}
assert amounts["membership-fee:2026:first-half"] == "37.50"
assert amounts["membership-fee:2026:second-half"] == "75.00"
descriptions = {
claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims
}
assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)")
assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026"
assert state["run_counter"] == 2
assert state["last_completed_run"] == "2026-04-15:000002"
@@ -121,6 +126,9 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
"membership-fee:2026:annual",
}
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith(
"(9 Monate)"
)
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
@@ -169,6 +177,23 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
}
def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01")
member.status = "resigned_end_of_year"
member.accepted_at = "2024-01-01"
member.membership_started_at = "2024-01-01"
member.membership_ended_at = "2026-12-31"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2026:annual" in claim_keys
assert "membership-fee:2027:annual" not in claim_keys
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()