fix(reconcile): handle '?' expected fee in fill-first allocation

Juniors with exactly 1 session get expected='?' (manual-review marker
from attendance.py). The fill-first allocation block summed and cast
expected values numerically without guarding against this, causing a
TypeError: unsupported operand type(s) for +: 'int' and 'str' on the
/juniors route whenever any matched payment landed on such a month.

Add _expected_amount() helper that coerces non-numeric markers to 0
(same convention the final-balance calculation at line 512 already used)
and apply it in the two failing spots plus the existing isinstance check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:10:41 +02:00
parent 72e29b1882
commit 20b618685f
2 changed files with 102 additions and 3 deletions

View File

@@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
def _expected_amount(value) -> float:
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
Juniors with exactly 1 session get expected='?' (manual-review marker).
Treat those as 0 for arithmetic so payments become surplus/credit.
"""
return value if isinstance(value, (int, float)) else 0
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -466,7 +475,7 @@ def reconcile(
if not in_window:
continue
total_expected = sum(e for _, e in in_window)
total_expected = sum(_expected_amount(e) for _, e in in_window)
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
@@ -477,7 +486,7 @@ def reconcile(
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(exp) - paid_so_far)
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
@@ -509,7 +518,7 @@ def reconcile(
final_balances: dict[str, int] = {}
for name in member_names:
window_balance = sum(
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
int(mdata["paid"]) - _expected_amount(mdata["expected"])
for mdata in ledger[name].values()
)
final_balances[name] = window_balance + credits.get(name, 0)