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:
@@ -0,0 +1,99 @@
|
||||
# Member modal — raw payments debug list
|
||||
|
||||
## Context
|
||||
|
||||
When a payer's bank message doesn't follow our convention, [`infer_payments.py`](scripts/infer_payments.py) may map the transfer to the wrong period (or none), and today the member detail modal hides this — it only shows the post-allocation, per-month splits produced by [`reconcile()`](scripts/match_payments.py:295). To diagnose these cases the user needs to see the **original sheet rows** that were attributed to a member: full `Amount`, `Inferred Amount`, `Person`, `Purpose`, `Sender`, `Message`, `Bank ID`, `manual fix`. The list should be hidden by default and revealed by a small toggle, since it is only relevant during debugging.
|
||||
|
||||
## Approach
|
||||
|
||||
Reuse the grouping logic that already exists in the [`/payments` route](app.py:540-553): group raw `tx` dicts by parsed `Person`, expose that mapping to the modal, and render it on demand under a new collapsible section.
|
||||
|
||||
### 1. Backend — group raw txs by member
|
||||
|
||||
In [`app.py`](app.py):
|
||||
|
||||
- Factor the existing per-person grouping in [`payments()`](app.py:530-568) into a small helper near the top of the file:
|
||||
```python
|
||||
def group_payments_by_person(transactions):
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
continue # unmatched rows are not tied to a member
|
||||
for p in person.split(","):
|
||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||
if not p:
|
||||
continue
|
||||
grouped.setdefault(p, []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
return grouped
|
||||
```
|
||||
Call it from [`payments()`](app.py:530), [`adults_view()`](app.py:160) and [`juniors_view()`](app.py:326) — the existing `payments()` body collapses to one line.
|
||||
|
||||
- In `adults_view()` and `juniors_view()`, after `transactions = get_cached_data(...)`, build `raw_payments_by_person = group_payments_by_person(transactions)` and pass it to `render_template` as `raw_payments_json=json.dumps(raw_payments_by_person)`.
|
||||
|
||||
- Note: rows where `Person` is empty are skipped on purpose — those have no member to attach to and are already shown by the dashboard's `Unmatched` block.
|
||||
|
||||
### 2. Templates — add a collapsible raw section to the modal
|
||||
|
||||
In [`templates/adults.html`](templates/adults.html) and [`templates/juniors.html`](templates/juniors.html), make the same structural and JS changes (the modal markup is mirrored in both files — `adults.html:677-682` and `juniors.html:658-663`).
|
||||
|
||||
- Inject the new dataset alongside the existing `memberData`:
|
||||
```html
|
||||
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||
```
|
||||
(next to [`adults.html:696`](templates/adults.html#L696)).
|
||||
|
||||
- Add a new section directly **after** the Payment History block:
|
||||
```html
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">
|
||||
Raw Payments
|
||||
<a href="#" id="rawPaymentsToggle" class="raw-toggle"
|
||||
onclick="toggleRawPayments(event)">[show]</a>
|
||||
</div>
|
||||
<div id="modalRawList" class="tx-list" style="display: none;">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Add a small CSS rule for `.raw-toggle` (muted color, smaller font, `margin-left: 8px`) — a few lines next to the existing `.modal-section-title` style. Don't restyle the whole modal.
|
||||
|
||||
- In `showMemberDetails(name)`:
|
||||
- Reset the toggle to `[show]` and the `#modalRawList` to `display: none` on every open (so the state doesn't leak between members).
|
||||
- Populate `#modalRawList` from `rawPaymentsByPerson[name] || []`. For each row render: `Date | Purpose` on the meta line, `Amount CZK` (with `Inferred: X CZK` annotation when `inferred_amount` differs from `amount`), `Sender`, `Person` (full string — useful when split between multiple people), `Message`, and a small footer with `Bank ID` and a `[manual fix]` marker if `manual_fix` is truthy. Reuse the existing `tx-item` / `tx-meta` / `tx-main` / `tx-msg` styles to match the rest of the modal.
|
||||
- When the list is empty, render `<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>` (same idiom used at [`adults.html:813`](templates/adults.html#L813)).
|
||||
|
||||
- Add the toggle handler near `closeModal`:
|
||||
```js
|
||||
function toggleRawPayments(ev) {
|
||||
ev.preventDefault();
|
||||
const list = document.getElementById('modalRawList');
|
||||
const link = document.getElementById('rawPaymentsToggle');
|
||||
const hidden = list.style.display === 'none';
|
||||
list.style.display = hidden ? 'block' : 'none';
|
||||
link.textContent = hidden ? '[hide]' : '[show]';
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Why not extend `reconcile()` instead
|
||||
|
||||
`reconcile()` already collapses each row into per-month allocated shares and drops `purpose`, `inferred_amount`, `bank_id`, `manual_fix`, and the gross `amount` ([trace](scripts/match_payments.py:436-469)). Carrying the raw `tx` through `reconcile()` would inflate the contract for every consumer when only the modal needs it. Grouping the already-fetched `transactions` list at the route level is one extra dict per request and reuses the cached payments data — no new sheet reads.
|
||||
|
||||
## Critical files
|
||||
|
||||
- [app.py](app.py) — add `group_payments_by_person()` helper; call it in `adults_view()`, `juniors_view()`, and `payments()`; pass `raw_payments_json` to the two dashboard templates.
|
||||
- [templates/adults.html](templates/adults.html) — modal section + JS + tiny CSS for the toggle link.
|
||||
- [templates/juniors.html](templates/juniors.html) — same changes as adults.html.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make web-debug` and open `http://localhost:5001/adults`.
|
||||
2. Pick a member known to have multiple payments (use the existing `/payments` page as a cross-reference).
|
||||
3. Click `[i]` → modal opens, raw list is hidden, link shows `[show]`. Click the link → list appears with the raw rows; click again → hides, link returns to `[show]`.
|
||||
4. Switch to another member via keyboard (ArrowDown) — the toggle resets to hidden and the list updates to the new member's rows (no leaking).
|
||||
5. Compare the raw rows in the modal against the `/payments` page grouping for the same person — same set of rows, same `Date`/`Amount`/`Message`.
|
||||
6. Pick a row with a non-conformant message (e.g. one where `Person` was inferred to multiple people) — confirm `Person` shows the full comma-separated string and `Inferred Amount` is visible when it differs from `Amount`.
|
||||
7. Repeat the click-through on `/juniors` to confirm parity.
|
||||
8. `make test` — no backend behavior change is expected, but run to catch template/route smoke breakage.
|
||||
Reference in New Issue
Block a user