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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user