feat: Implement junior fees dashboard and reconciliation
- Add dual-sheet architecture to pull attendance from both adult and junior spreadsheets. - Introduce parsing rules to isolate juniors (e.g. above '# Treneri', tier 'J'). - Add new endpoints `/fees-juniors` and `/reconcile-juniors` to track junior attendances and match bank payments. - Display granular attendance components showing adult vs. junior practices. - Add fee rule configuration supporting custom pricing exceptions for specific months (e.g. Sep 2025) and merging billing periods. - Add `make sync-2025` target to the Makefile for convenience. - Document junior fees implementation logic and rules in prompts/outcomes. Co-authored-by: Antigravity <antigravity@google.com>
This commit is contained in:
214
app.py
214
app.py
@@ -12,9 +12,28 @@ from flask import Flask, render_template, g, send_file, request
|
||||
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 attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, MERGED_MONTHS
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
|
||||
|
||||
def get_month_labels(sorted_months):
|
||||
labels = {}
|
||||
for m in sorted_months:
|
||||
dt = datetime.strptime(m, "%Y-%m")
|
||||
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
|
||||
merged_in = sorted([k for k, v in MERGED_MONTHS.items() if v == m])
|
||||
if merged_in:
|
||||
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
|
||||
years = {d.year for d in all_dts}
|
||||
if len(years) > 1:
|
||||
parts = [d.strftime("%b %Y") for d in all_dts]
|
||||
labels[m] = "+".join(parts)
|
||||
else:
|
||||
parts = [d.strftime("%b") for d in all_dts]
|
||||
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
||||
else:
|
||||
labels[m] = dt.strftime("%b %Y")
|
||||
return labels
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Bank account for QR code payments (can be overridden by ENV)
|
||||
@@ -68,9 +87,7 @@ def fees():
|
||||
results = [(name, fees) for name, tier, fees in members if tier == "A"]
|
||||
|
||||
# Format month labels
|
||||
month_labels = {
|
||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||
}
|
||||
month_labels = get_month_labels(sorted_months)
|
||||
|
||||
monthly_totals = {m: 0 for m in sorted_months}
|
||||
|
||||
@@ -85,7 +102,6 @@ def fees():
|
||||
norm_name = normalize(name)
|
||||
for m in sorted_months:
|
||||
fee, count = month_fees.get(m, (0, 0))
|
||||
monthly_totals[m] += fee
|
||||
|
||||
# Check for exception
|
||||
norm_period = normalize(m)
|
||||
@@ -96,6 +112,8 @@ def fees():
|
||||
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
|
||||
is_overridden = True
|
||||
else:
|
||||
if isinstance(fee, int):
|
||||
monthly_totals[m] += fee
|
||||
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||
is_overridden = False
|
||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||
@@ -112,6 +130,82 @@ def fees():
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/fees-juniors")
|
||||
def fees_juniors():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
members, sorted_months = get_junior_members_with_fees()
|
||||
record_step("fetch_junior_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
# Sort members by name
|
||||
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
|
||||
|
||||
# Format month labels
|
||||
month_labels = get_month_labels(sorted_months)
|
||||
|
||||
monthly_totals = {m: 0 for m in sorted_months}
|
||||
|
||||
# Get exceptions for formatting (reusing payments sheet)
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
formatted_results = []
|
||||
for name, month_fees in results:
|
||||
row = {"name": name, "months": []}
|
||||
norm_name = normalize(name)
|
||||
for m in sorted_months:
|
||||
fee_data = month_fees.get(m, (0, 0, 0, 0))
|
||||
if len(fee_data) == 4:
|
||||
fee, total_count, adult_count, junior_count = fee_data
|
||||
else:
|
||||
fee, total_count = fee_data
|
||||
adult_count, junior_count = 0, 0
|
||||
|
||||
# Check for exception
|
||||
norm_period = normalize(m)
|
||||
ex_data = exceptions.get((norm_name, norm_period))
|
||||
override_amount = ex_data["amount"] if ex_data else None
|
||||
|
||||
if ex_data is None and isinstance(fee, int):
|
||||
monthly_totals[m] += fee
|
||||
|
||||
# Formulate the count string display
|
||||
if adult_count > 0 and junior_count > 0:
|
||||
count_str = f"{total_count} ({adult_count}A+{junior_count}J)"
|
||||
elif adult_count > 0:
|
||||
count_str = f"{total_count} (A)"
|
||||
elif junior_count > 0:
|
||||
count_str = f"{total_count} (J)"
|
||||
else:
|
||||
count_str = f"{total_count}"
|
||||
|
||||
if override_amount is not None and override_amount != fee:
|
||||
cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK"
|
||||
is_overridden = True
|
||||
else:
|
||||
if fee == "?":
|
||||
cell = f"? / {count_str}" if total_count > 0 else "-"
|
||||
else:
|
||||
cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-"
|
||||
is_overridden = False
|
||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||
formatted_results.append(row)
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"fees-juniors.html",
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
results=formatted_results,
|
||||
totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()],
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/reconcile")
|
||||
def reconcile_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
@@ -133,9 +227,7 @@ def reconcile_view():
|
||||
record_step("reconcile")
|
||||
|
||||
# Format month labels
|
||||
month_labels = {
|
||||
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||
}
|
||||
month_labels = get_month_labels(sorted_months)
|
||||
|
||||
# Filter to adults for the main table
|
||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||
@@ -194,6 +286,112 @@ def reconcile_view():
|
||||
raw_months=sorted_months,
|
||||
results=formatted_results,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=unmatched,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT
|
||||
)
|
||||
|
||||
@app.route("/reconcile-juniors")
|
||||
def reconcile_juniors_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
junior_members, sorted_months = get_junior_members_with_fees()
|
||||
record_step("fetch_junior_members")
|
||||
if not junior_members:
|
||||
return "No data."
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
||||
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
|
||||
adapted_members = []
|
||||
for name, tier, fees_dict in junior_members:
|
||||
adapted_fees = {}
|
||||
for m, fee_data in fees_dict.items():
|
||||
if len(fee_data) == 4:
|
||||
fee, total_count, _, _ = fee_data
|
||||
adapted_fees[m] = (fee, total_count)
|
||||
else:
|
||||
fee, count = fee_data
|
||||
adapted_fees[m] = (fee, count)
|
||||
adapted_members.append((name, tier, adapted_fees))
|
||||
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
# Format month labels
|
||||
month_labels = get_month_labels(sorted_months)
|
||||
|
||||
# Filter to juniors for the main table
|
||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||
|
||||
formatted_results = []
|
||||
for name in junior_names:
|
||||
data = result["members"][name]
|
||||
row = {"name": name, "months": [], "balance": data["total_balance"]}
|
||||
for m in sorted_months:
|
||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
|
||||
expected = mdata["expected"]
|
||||
paid = int(mdata["paid"])
|
||||
|
||||
status = "empty"
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||
if expected == "?":
|
||||
status = "empty"
|
||||
cell_text = "?"
|
||||
elif paid >= expected:
|
||||
status = "ok"
|
||||
cell_text = "OK"
|
||||
elif paid > 0:
|
||||
status = "partial"
|
||||
cell_text = f"{paid}/{expected}"
|
||||
amount_to_pay = expected - paid
|
||||
else:
|
||||
status = "unpaid"
|
||||
cell_text = f"UNPAID {expected}"
|
||||
amount_to_pay = expected
|
||||
elif paid > 0:
|
||||
status = "surplus"
|
||||
cell_text = f"PAID {paid}"
|
||||
|
||||
row["months"].append({
|
||||
"text": cell_text,
|
||||
"status": status,
|
||||
"amount": amount_to_pay,
|
||||
"month": month_labels[m]
|
||||
})
|
||||
|
||||
row["balance"] = data["total_balance"]
|
||||
formatted_results.append(row)
|
||||
|
||||
# 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"])
|
||||
unmatched = result["unmatched"]
|
||||
import json
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"reconcile-juniors.html",
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
raw_months=sorted_months,
|
||||
results=formatted_results,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=unmatched,
|
||||
|
||||
Reference in New Issue
Block a user