diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..082b194 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/app.py b/app.py index 03740bd..4a163ee 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ scripts_dir = Path(__file__).parent / "scripts" sys.path.append(str(scripts_dir)) from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID -from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID +from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID app = Flask(__name__) @@ -37,14 +37,29 @@ def fees(): monthly_totals = {m: 0 for m in sorted_months} + # Get exceptions for formatting + credentials_path = ".secret/fuj-management-bot-credentials.json" + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + formatted_results = [] for name, month_fees in results: row = {"name": name, "months": []} + norm_name = normalize(name) for m in sorted_months: fee, count = month_fees.get(m, (0, 0)) monthly_totals[m] += fee - cell = f"{fee} CZK ({count})" if count > 0 else "-" - row["months"].append(cell) + + # Check for exception + norm_period = normalize(m) + override = exceptions.get((norm_name, norm_period)) + + if override is not None and override != fee: + cell = f"{override} ({fee}) CZK ({count})" if count > 0 else f"{override} ({fee}) CZK" + is_overridden = True + else: + cell = f"{fee} CZK ({count})" if count > 0 else "-" + is_overridden = False + row["months"].append({"cell": cell, "overridden": is_overridden}) formatted_results.append(row) return render_template( @@ -69,7 +84,8 @@ def reconcile_view(): return "No data." transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path) - result = reconcile(members, sorted_months, transactions) + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + result = reconcile(members, sorted_months, transactions, exceptions) # Format month labels month_labels = { @@ -84,8 +100,9 @@ def reconcile_view(): data = result["members"][name] row = {"name": name, "months": [], "balance": data["total_balance"]} for m in sorted_months: - mdata = data["months"].get(m, {"expected": 0, "paid": 0}) + mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] + original = mdata["original_expected"] paid = int(mdata["paid"]) cell_status = "" @@ -106,14 +123,16 @@ def reconcile_view(): # Format credits and debts credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"]) debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"]) - # Format unmatched unmatched = result["unmatched"] + import json return render_template( "reconcile.html", months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, results=formatted_results, + member_data=json.dumps(result["members"]), credits=credits, debts=debts, unmatched=unmatched, diff --git a/scripts/match_payments.py b/scripts/match_payments.py index e7fba02..7a274e4 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -233,10 +233,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]: return transactions +def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]: + """Fetch manual fee overrides from the 'exceptions' sheet. + + Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}. + """ + service = get_sheets_service(credentials_path) + try: + result = service.spreadsheets().values().get( + spreadsheetId=spreadsheet_id, + range="'exceptions'!A2:D", + valueRenderOption="UNFORMATTED_VALUE" + ).execute() + rows = result.get("values", []) + except Exception as e: + print(f"Warning: Could not fetch exceptions: {e}") + return {} + + exceptions = {} + for row in rows: + if len(row) < 3 or str(row[0]).lower().startswith("name"): + continue + + name = str(row[0]).strip() + period = str(row[1]).strip() + # Robust normalization using czech_utils.normalize + norm_name = normalize(name) + norm_period = normalize(period) + + try: + amount = int(row[2]) + note = str(row[3]).strip() if len(row) > 3 else "" + exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note} + except (ValueError, TypeError): + continue + + return exceptions + + def reconcile( members: list[tuple[str, str, dict[str, int]]], sorted_months: list[str], transactions: list[dict], + exceptions: dict[tuple[str, str], dict] = None, ) -> dict: """Match transactions to members and months. @@ -251,11 +290,30 @@ def reconcile( # Initialize ledger ledger: dict[str, dict[str, dict]] = {} + exceptions = exceptions or {} for name in member_names: ledger[name] = {} for m in sorted_months: + # Robust normalization for lookup + norm_name = normalize(name) + norm_period = normalize(m) + fee_data = member_fees[name].get(m, (0, 0)) + original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data + attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0 + + ex_data = exceptions.get((norm_name, norm_period)) + if ex_data is not None: + expected = ex_data["amount"] + exception_info = ex_data + else: + expected = original_expected + exception_info = None + ledger[name][m] = { - "expected": member_fees[name].get(m, 0), + "expected": expected, + "original_expected": original_expected, + "attendance_count": attendance_count, + "exception": exception_info, "paid": 0, "transactions": [], } @@ -392,10 +450,12 @@ def print_report(result: dict, sorted_months: list[str]): for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "paid": 0}) expected = mdata["expected"] + original = mdata["original_expected"] paid = int(mdata["paid"]) total_expected += expected total_paid += paid - + + cell_status = "" if expected == 0 and paid == 0: cell = "-" elif paid >= expected and expected > 0: @@ -404,6 +464,7 @@ def print_report(result: dict, sorted_months: list[str]): cell = f"{paid}/{expected}" else: cell = f"UNPAID {expected}" + member_balance += paid - expected line += f" | {cell:>10}" balance_str = f"{member_balance:+d}" if member_balance != 0 else "0" @@ -509,7 +570,11 @@ def main(): print(f"Processing {len(transactions)} transactions.\n") - result = reconcile(members, sorted_months, transactions) + exceptions = fetch_exceptions(args.sheet_id, args.credentials) + if exceptions: + print(f"Loaded {len(exceptions)} fee exceptions.") + + result = reconcile(members, sorted_months, transactions, exceptions) print_report(result, sorted_months) diff --git a/templates/fees.html b/templates/fees.html index ebc4ed3..df601f1 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -102,6 +102,11 @@ /* Light gray for normal cells */ } + .cell-overridden { + color: #ffa500 !important; + /* Orange for overrides */ + } + .nav { margin-bottom: 20px; font-size: 12px; @@ -175,8 +180,11 @@ {% for row in results %}
| Balance | - + {% for row in results %} -|
|---|---|
| {{ row.name }} | +|
| + {{ row.name }} + + | {% for cell in row.months %}
@@ -275,6 +443,183 @@
{% endif %}
+
+
+
+
+
|