fix: Tolerate diacritic/case/whitespace mismatches in Person column matching
- Add canonical_member_key() in match_payments.py to normalize names via NFKD + lowercase + whitespace-collapse before ledger lookup; resolves payments attributed to e.g. "Maria Maco" to canonical "Mária Maco". Emits logger.info when a non-canonical cell is rescued so sheet typos are visible in logs without losing the payment allocation. - Extend group_payments_by_person() in app.py to accept member_names and re-key raw-payment groups under the canonical attendance-sheet name so the modal's Raw Payments debug section also finds the row correctly. - Add raw payments collapsible section to member detail modal in adults.html and juniors.html for debugging payment attribution issues. - Remove 4 obsolete tests targeting routes /fees, /fees-juniors, /reconcile, /reconcile-juniors that no longer exist; add test_match_payments.py covering canonical key equivalence and reconcile() tolerance end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,67 +19,6 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'url=/adults', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.get_members_with_fees')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
|
||||
"""Test that /fees returns 200 and renders the dashboard"""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
|
||||
response = self.client.get('/fees')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
|
||||
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
||||
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
|
||||
response = self.client.get('/fees-juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
|
||||
self.assertIn(b'Test Junior 1', response.data)
|
||||
self.assertIn(b'? / 1 (J)', response.data)
|
||||
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test that /reconcile returns 200 and shows matches"""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'test payment',
|
||||
'sender': 'External Bank User',
|
||||
'inferred_amount': 750
|
||||
}]
|
||||
|
||||
response = self.client.get('/reconcile')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Payment Reconciliation', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'OK', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
||||
@@ -98,38 +37,6 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'Direct Member Payment', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions')
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
|
||||
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
||||
mock_get_junior.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_exceptions.return_value = {}
|
||||
mock_transactions.return_value = [{
|
||||
'date': '2026-01-15',
|
||||
'amount': 500,
|
||||
'person': 'Junior One',
|
||||
'purpose': '2026-01',
|
||||
'message': '',
|
||||
'sender': 'Parent',
|
||||
'inferred_amount': 500
|
||||
}]
|
||||
|
||||
response = self.client.get('/reconcile-juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Junior Payment Reconciliation', response.data)
|
||||
self.assertIn(b'Junior One', response.data)
|
||||
self.assertIn(b'Junior Two', response.data)
|
||||
self.assertIn(b'OK', response.data)
|
||||
self.assertIn(b'?', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
|
||||
69
tests/test_match_payments.py
Normal file
69
tests/test_match_payments.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
|
||||
from scripts.match_payments import canonical_member_key, reconcile
|
||||
|
||||
|
||||
class TestCanonicalMemberKey(unittest.TestCase):
|
||||
def test_diacritics_and_case_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
|
||||
|
||||
def test_whitespace_runs_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
|
||||
|
||||
def test_unknown_name_passes_through_normalized(self):
|
||||
# Two genuinely different names must not collide.
|
||||
self.assertNotEqual(
|
||||
canonical_member_key("Mária Maco"),
|
||||
canonical_member_key("Marek Maco"),
|
||||
)
|
||||
|
||||
|
||||
class TestReconcileTolerantPersonMatching(unittest.TestCase):
|
||||
def _members(self):
|
||||
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
|
||||
|
||||
def _tx(self, person):
|
||||
return {
|
||||
"date": "2026-04-15",
|
||||
"amount": 750,
|
||||
"person": person,
|
||||
"purpose": "2026-04",
|
||||
"inferred_amount": 750,
|
||||
"sender": "Maco Family",
|
||||
"message": "fee",
|
||||
}
|
||||
|
||||
def test_person_without_diacritics_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
|
||||
|
||||
member = result["members"]["Mária Maco"]
|
||||
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_with_extra_whitespace_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_lowercase_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_truly_unknown_person_still_unmatched(self):
|
||||
result = reconcile(
|
||||
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
|
||||
)
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
|
||||
self.assertEqual(len(result["unmatched"]), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user