Compare commits
4 Commits
feat/fuj-s
...
feat/m5-py
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eec51bb34 | |||
| b562ce3201 | |||
| f0de300292 | |||
| 2164e99866 |
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
|
||||||
|
|
||||||
|
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
|
||||||
|
- Added `LOG_LEVEL=DEBUG` debug logging on the Fio fetch path: client variant selected, full GET URL (token redacted on API path), HTTP status, body bytes, and per-parse drop-reason counters (`raw_rows`, `kept`, `dropped_bad_date`, `dropped_nonpositive_amount`). Key files: `go/internal/io/fio/{client,api,transparent}.go`.
|
||||||
|
- Fixed `parseCzechDate` to accept `DD.MM.YY` (2-digit year) in addition to the 4-digit variant — Fio's transparent page now serves this format. Key file: `go/internal/io/fio/transparent.go`.
|
||||||
|
- Added `make go-sync-debug [DAYS=N]` Makefile target (default 30 days).
|
||||||
|
|
||||||
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
|
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
|
||||||
|
|
||||||
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.
|
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.
|
||||||
|
|||||||
424
app.py
424
app.py
@@ -21,8 +21,14 @@ from config import (
|
|||||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||||
)
|
)
|
||||||
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
|
from views import (
|
||||||
|
build_adults_view_model,
|
||||||
|
build_juniors_view_model,
|
||||||
|
build_payments_view_model,
|
||||||
|
adapt_junior_members,
|
||||||
|
)
|
||||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
||||||
from sync_fio_to_sheets import sync_to_sheets
|
from sync_fio_to_sheets import sync_to_sheets
|
||||||
from infer_payments import infer_payments
|
from infer_payments import infer_payments
|
||||||
@@ -38,44 +44,6 @@ def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, dese
|
|||||||
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_month_labels(sorted_months, merged_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
|
|
||||||
|
|
||||||
def group_payments_by_person(transactions, member_names=None):
|
|
||||||
canonical_by_key = (
|
|
||||||
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
|
||||||
)
|
|
||||||
grouped = {}
|
|
||||||
for tx in transactions:
|
|
||||||
person = str(tx.get("person", "")).strip()
|
|
||||||
if not person:
|
|
||||||
continue
|
|
||||||
for p in person.split(","):
|
|
||||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
key = canonical_by_key.get(canonical_member_key(p), p)
|
|
||||||
grouped.setdefault(key, []).append(tx)
|
|
||||||
for rows in grouped.values():
|
|
||||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
def warmup_cache():
|
def warmup_cache():
|
||||||
"""Pre-fetch all cached data so first request is fast."""
|
"""Pre-fetch all cached data so first request is fast."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -181,381 +149,77 @@ def adults_view():
|
|||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
credentials_path = CREDENTIALS_PATH
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
record_step("fetch_members")
|
record_step("fetch_members")
|
||||||
if not members_data:
|
if not members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
members, sorted_months = members_data
|
members, sorted_months = members_data
|
||||||
|
|
||||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
record_step("fetch_payments")
|
record_step("fetch_payments")
|
||||||
exceptions = get_cached_data(
|
exceptions = get_cached_data(
|
||||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
PAYMENTS_SHEET_ID, credentials_path,
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
)
|
)
|
||||||
record_step("fetch_exceptions")
|
record_step("fetch_exceptions")
|
||||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
record_step("reconcile")
|
record_step("reconcile")
|
||||||
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
|
||||||
current_month = datetime.now().strftime("%Y-%m")
|
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
vm = build_adults_view_model(
|
||||||
formatted_results = []
|
members, sorted_months, result, transactions,
|
||||||
for name in adult_names:
|
datetime.now().strftime("%Y-%m"),
|
||||||
data = result["members"][name]
|
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
|
||||||
unpaid_months = []
|
|
||||||
raw_unpaid_months = []
|
|
||||||
for m in sorted_months:
|
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
|
||||||
expected = mdata.get("expected", 0)
|
|
||||||
original_expected = mdata.get("original_expected", 0)
|
|
||||||
count = mdata.get("attendance_count", 0)
|
|
||||||
paid = int(mdata.get("paid", 0))
|
|
||||||
exception_info = mdata.get("exception", None)
|
|
||||||
|
|
||||||
monthly_totals[m]["expected"] += expected
|
|
||||||
monthly_totals[m]["paid"] += paid
|
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
|
||||||
is_overridden = True
|
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
|
||||||
else:
|
|
||||||
is_overridden = False
|
|
||||||
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
|
||||||
|
|
||||||
status = "empty"
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected > 0:
|
|
||||||
amount_to_pay = max(0, expected - paid)
|
|
||||||
if paid >= expected:
|
|
||||||
status = "ok"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"0/{fee_display}"
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
else:
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected > 0 or paid > 0:
|
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
|
||||||
else:
|
|
||||||
tooltip = ""
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"overridden": is_overridden,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m],
|
|
||||||
"raw_month": m,
|
|
||||||
"tooltip": tooltip
|
|
||||||
})
|
|
||||||
|
|
||||||
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
|
||||||
settled_balance = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
|
||||||
|
|
||||||
payable_amount = max(0, -settled_balance)
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
|
||||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
|
||||||
row["balance"] = settled_balance
|
|
||||||
row["payable_amount"] = payable_amount
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
formatted_totals = []
|
|
||||||
for m in sorted_months:
|
|
||||||
t = monthly_totals[m]
|
|
||||||
status = "empty"
|
|
||||||
if t["expected"] > 0 or t["paid"] > 0:
|
|
||||||
if t["paid"] == t["expected"]:
|
|
||||||
status = "ok"
|
|
||||||
elif t["paid"] < t["expected"]:
|
|
||||||
status = "unpaid"
|
|
||||||
else:
|
|
||||||
status = "surplus"
|
|
||||||
|
|
||||||
formatted_totals.append({
|
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
|
|
||||||
def settled_balance(name):
|
|
||||||
data = result["members"][name]
|
|
||||||
total = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
total += int(mdata.get("paid", 0)) - exp
|
|
||||||
return total
|
|
||||||
|
|
||||||
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
|
|
||||||
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
|
|
||||||
unmatched = result["unmatched"]
|
|
||||||
import json
|
|
||||||
|
|
||||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"adults.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
totals=formatted_totals,
|
|
||||||
member_data=json.dumps(result["members"]),
|
|
||||||
month_labels_json=json.dumps(month_labels),
|
|
||||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
|
||||||
credits=credits,
|
|
||||||
debts=debts,
|
|
||||||
unmatched=unmatched,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
current_month=current_month
|
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("adults.html", **vm)
|
||||||
|
|
||||||
@app.route("/juniors")
|
@app.route("/juniors")
|
||||||
def juniors_view():
|
def juniors_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
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"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
credentials_path = CREDENTIALS_PATH
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
record_step("fetch_junior_members")
|
record_step("fetch_junior_members")
|
||||||
if not junior_members_data:
|
if not junior_members_data:
|
||||||
return "No data."
|
return "No data."
|
||||||
junior_members, sorted_months = junior_members_data
|
junior_members, sorted_months = junior_members_data
|
||||||
|
|
||||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
record_step("fetch_payments")
|
record_step("fetch_payments")
|
||||||
exceptions = get_cached_data(
|
exceptions = get_cached_data(
|
||||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
PAYMENTS_SHEET_ID, credentials_path,
|
PAYMENTS_SHEET_ID, credentials_path,
|
||||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
)
|
)
|
||||||
record_step("fetch_exceptions")
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
adapted_members = adapt_junior_members(junior_members)
|
||||||
# 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)
|
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||||
record_step("reconcile")
|
record_step("reconcile")
|
||||||
|
|
||||||
# Format month labels
|
|
||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
|
||||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
|
||||||
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
|
||||||
current_month = datetime.now().strftime("%Y-%m")
|
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
vm = build_juniors_view_model(
|
||||||
formatted_results = []
|
junior_members, adapted_members, sorted_months, result, transactions,
|
||||||
for name in junior_names:
|
datetime.now().strftime("%Y-%m"),
|
||||||
data = result["members"][name]
|
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
|
||||||
unpaid_months = []
|
|
||||||
raw_unpaid_months = []
|
|
||||||
for m in sorted_months:
|
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
|
||||||
expected = mdata.get("expected", 0)
|
|
||||||
original_expected = mdata.get("original_expected", 0)
|
|
||||||
count = mdata.get("attendance_count", 0)
|
|
||||||
paid = int(mdata.get("paid", 0))
|
|
||||||
exception_info = mdata.get("exception", None)
|
|
||||||
|
|
||||||
if expected != "?" and isinstance(expected, int):
|
|
||||||
monthly_totals[m]["expected"] += expected
|
|
||||||
monthly_totals[m]["paid"] += paid
|
|
||||||
|
|
||||||
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
|
||||||
adult_count = 0
|
|
||||||
junior_count = 0
|
|
||||||
if orig_fee_data and len(orig_fee_data) == 4:
|
|
||||||
_, _, adult_count, junior_count = orig_fee_data
|
|
||||||
|
|
||||||
breakdown = ""
|
|
||||||
if adult_count > 0 and junior_count > 0:
|
|
||||||
breakdown = f":{junior_count}J,{adult_count}A"
|
|
||||||
elif junior_count > 0:
|
|
||||||
breakdown = f":{junior_count}J"
|
|
||||||
elif adult_count > 0:
|
|
||||||
breakdown = f":{adult_count}A"
|
|
||||||
|
|
||||||
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
|
||||||
is_overridden = True
|
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
|
||||||
else:
|
|
||||||
is_overridden = False
|
|
||||||
fee_display = f"{expected} CZK{count_str}"
|
|
||||||
|
|
||||||
status = "empty"
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
|
||||||
if expected == "?":
|
|
||||||
status = "empty"
|
|
||||||
cell_text = f"?{count_str}"
|
|
||||||
elif paid >= expected:
|
|
||||||
status = "ok"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
amount_to_pay = expected - paid
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"0/{fee_display}"
|
|
||||||
amount_to_pay = expected
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
|
||||||
else:
|
|
||||||
tooltip = ""
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"overridden": is_overridden,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m],
|
|
||||||
"raw_month": m,
|
|
||||||
"tooltip": tooltip
|
|
||||||
})
|
|
||||||
|
|
||||||
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
|
||||||
settled_balance = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
|
||||||
|
|
||||||
payable_amount = max(0, -settled_balance)
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
|
||||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
|
||||||
row["balance"] = settled_balance
|
|
||||||
row["payable_amount"] = payable_amount
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
formatted_totals = []
|
|
||||||
for m in sorted_months:
|
|
||||||
t = monthly_totals[m]
|
|
||||||
status = "empty"
|
|
||||||
if t["expected"] > 0 or t["paid"] > 0:
|
|
||||||
if t["paid"] == t["expected"]:
|
|
||||||
status = "ok"
|
|
||||||
elif t["paid"] < t["expected"]:
|
|
||||||
status = "unpaid"
|
|
||||||
else:
|
|
||||||
status = "surplus"
|
|
||||||
|
|
||||||
formatted_totals.append({
|
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
|
|
||||||
# Format credits and debts
|
|
||||||
def junior_settled_balance(name):
|
|
||||||
data = result["members"][name]
|
|
||||||
total = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
total += int(mdata.get("paid", 0)) - exp
|
|
||||||
return total
|
|
||||||
|
|
||||||
junior_all_names = [name for name, _, _ in adapted_members]
|
|
||||||
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
|
|
||||||
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
|
|
||||||
unmatched = result["unmatched"]
|
|
||||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
|
|
||||||
import json
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"juniors.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
totals=formatted_totals,
|
|
||||||
member_data=json.dumps(result["members"]),
|
|
||||||
month_labels_json=json.dumps(month_labels),
|
|
||||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
|
||||||
credits=credits,
|
|
||||||
debts=debts,
|
|
||||||
unmatched=unmatched,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
current_month=current_month
|
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("juniors.html", **vm)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
def payments():
|
def payments():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
credentials_path = CREDENTIALS_PATH
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
record_step("fetch_payments")
|
record_step("fetch_payments")
|
||||||
|
|
||||||
@@ -567,23 +231,13 @@ def payments():
|
|||||||
if juniors_data:
|
if juniors_data:
|
||||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||||
|
|
||||||
grouped = group_payments_by_person(transactions, member_names)
|
vm = build_payments_view_model(
|
||||||
# payments page also groups unmatched rows under a fallback key
|
transactions, member_names,
|
||||||
for tx in transactions:
|
|
||||||
if not str(tx.get("person", "")).strip():
|
|
||||||
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
|
||||||
for rows in grouped.values():
|
|
||||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
|
||||||
sorted_people = sorted(grouped.keys())
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
return render_template(
|
|
||||||
"payments.html",
|
|
||||||
grouped_payments=grouped,
|
|
||||||
sorted_people=sorted_people,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url
|
payments_url=payments_url,
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("payments.html", **vm)
|
||||||
|
|
||||||
@app.route("/qr")
|
@app.route("/qr")
|
||||||
def qr_code():
|
def qr_code():
|
||||||
|
|||||||
185
docs/plans/2026-05-07-1431-m5-json-api-parity.md
Normal file
185
docs/plans/2026-05-07-1431-m5-json-api-parity.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Python view-model cleanup (M5 prep — Python side only)
|
||||||
|
|
||||||
|
Scoped-down precursor to M5 of the Go rewrite. See:
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Python app is still production. Before adding `/api/X` shadow
|
||||||
|
routes, JSON DTOs, parity tooling, and Go handlers, get the Python
|
||||||
|
side into a clean shape. Today the `/adults` and `/juniors` routes
|
||||||
|
each carry 150–200 lines of inline view-model construction inside
|
||||||
|
the Flask handler:
|
||||||
|
|
||||||
|
- [adults_view](app.py#L179) — 167 LOC, computes per-row
|
||||||
|
status/cell_text/balance/credits/debts inline.
|
||||||
|
- [juniors_view](app.py#L347) — 205 LOC, same plus `"?"` sentinel
|
||||||
|
branching and `:NJ,MA` breakdown.
|
||||||
|
- [payments](app.py#L553) — 35 LOC, lighter but still mixes IO and
|
||||||
|
grouping.
|
||||||
|
|
||||||
|
Pulling that into pure builder functions:
|
||||||
|
- shrinks `app.py` and makes each route handler do only what a route
|
||||||
|
handler should (IO + cache + step timing + render);
|
||||||
|
- gives us **already-tested** pure functions that future M5 work can
|
||||||
|
call from a `/api/X` shadow endpoint with one line of `jsonify(...)`;
|
||||||
|
- has zero behavioural change (existing tests in
|
||||||
|
[test_app.py](tests/test_app.py) act as the regression guard).
|
||||||
|
|
||||||
|
This is a Python-only change. No Go work, no JSON contract, no shadow
|
||||||
|
routes — those come after.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Three pure builder functions in a new `scripts/views.py` module. Each
|
||||||
|
takes already-loaded, deserialized inputs (no Flask globals, no IO,
|
||||||
|
no cache calls) and returns the exact dict that today's route passes
|
||||||
|
to `render_template`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_adults_view_model(
|
||||||
|
members, sorted_months, transactions, exceptions, current_month,
|
||||||
|
*, attendance_url, payments_url, bank_account,
|
||||||
|
) -> dict: ...
|
||||||
|
|
||||||
|
def build_juniors_view_model(
|
||||||
|
junior_members, sorted_months, transactions, exceptions, current_month,
|
||||||
|
*, attendance_url, payments_url, bank_account,
|
||||||
|
) -> dict: ...
|
||||||
|
|
||||||
|
def build_payments_view_model(
|
||||||
|
transactions, member_names,
|
||||||
|
*, attendance_url, payments_url,
|
||||||
|
) -> dict: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The route handlers shrink to: load data (with `get_cached_data` and
|
||||||
|
`record_step` calls staying in `app.py`), call the builder, render.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route("/adults")
|
||||||
|
def adults_view():
|
||||||
|
members, sorted_months = ... # cached loads + record_step calls
|
||||||
|
transactions = ...
|
||||||
|
exceptions = ...
|
||||||
|
result_meta = ...
|
||||||
|
record_step("process_data")
|
||||||
|
vm = build_adults_view_model(
|
||||||
|
members, sorted_months, transactions, exceptions,
|
||||||
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
attendance_url=..., payments_url=..., bank_account=BANK_ACCOUNT,
|
||||||
|
)
|
||||||
|
return render_template("adults.html", **vm)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Pure builders, no Flask state.** No `record_step`, no `g.*`, no
|
||||||
|
`get_cached_data` inside builders. They take plain args, return
|
||||||
|
plain dicts. This is what makes them trivially unit-testable and,
|
||||||
|
later, trivially reusable from `/api/X`.
|
||||||
|
2. **Preserve byte-equal behaviour.** The dicts returned must match
|
||||||
|
today's `render_template(...)` kwargs key-for-key, value-for-value
|
||||||
|
— including the existing `json.dumps(member_data)` /
|
||||||
|
`json.dumps(month_labels_json)` / `json.dumps(raw_payments_json)`
|
||||||
|
wrappers. Those wrappers exist for inline JS in templates; the
|
||||||
|
refactor doesn't touch them. (When `/api/X` lands later, that route
|
||||||
|
will produce a sibling dict with the wrappers stripped, but that's
|
||||||
|
future work.)
|
||||||
|
3. **`reconcile()` stays inside the route, not the builder.** It
|
||||||
|
crosses the IO/cache boundary in spirit (its inputs come from
|
||||||
|
cache); but it's also pure-domain and called by both adults and
|
||||||
|
juniors. Keep the call site in the route so `record_step("reconcile")`
|
||||||
|
timing isn't lost. Builder takes `result` as an argument.
|
||||||
|
4. **Shared helpers stay in `app.py`** for now —
|
||||||
|
[get_month_labels](app.py#L41) and
|
||||||
|
[group_payments_by_person](app.py#L60) are already module-level
|
||||||
|
pure functions, used by routes and now by builders. Either leave
|
||||||
|
them in `app.py` and import into `scripts/views.py`, or move them
|
||||||
|
into `scripts/views.py`. **Choose: move into `scripts/views.py`**
|
||||||
|
— they're view-model concerns, not Flask concerns, and `app.py`
|
||||||
|
should keep shrinking.
|
||||||
|
5. **No new test file needed.** Existing
|
||||||
|
[tests/test_app.py](tests/test_app.py) tests
|
||||||
|
`test_adults_route`, `test_juniors_route`, `test_payments_route`
|
||||||
|
exercise the rendered HTML end-to-end. If they pass after the
|
||||||
|
refactor, behaviour is preserved. Adding builder-level unit tests
|
||||||
|
is a *nice-to-have* but not required for this iteration.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. Create `scripts/views.py` with the three builders + shared helpers
|
||||||
|
|
||||||
|
- Move `get_month_labels` and `group_payments_by_person` from
|
||||||
|
[app.py:41–77](app.py#L41-L77) into `scripts/views.py`. Update the
|
||||||
|
import in `app.py`.
|
||||||
|
- Implement `build_adults_view_model` by extracting
|
||||||
|
[app.py:200–344](app.py#L200-L344) (everything between `result =
|
||||||
|
reconcile(...)` and `return render_template(...)`). Take `result`
|
||||||
|
as a parameter; emit the same dict that's currently passed as
|
||||||
|
`**kwargs` to `render_template`.
|
||||||
|
- Implement `build_juniors_view_model` by extracting
|
||||||
|
[app.py:370–550](app.py#L370-L550). Same shape — including the
|
||||||
|
`adapted_members` adapter loop, the junior `?`-sentinel branches,
|
||||||
|
and the `:NJ,MA` breakdown.
|
||||||
|
- Implement `build_payments_view_model` by extracting
|
||||||
|
[app.py:570–586](app.py#L570-L586) (the `group_payments_by_person`
|
||||||
|
call + `Unmatched / Unknown` bucket + sort).
|
||||||
|
|
||||||
|
### 2. Slim down the route handlers in `app.py`
|
||||||
|
|
||||||
|
Each handler keeps:
|
||||||
|
- `attendance_url`/`payments_url` URL building
|
||||||
|
- `get_cached_data` calls (with `record_step` between)
|
||||||
|
- `reconcile(...)` call (adults/juniors) with `record_step("reconcile")`
|
||||||
|
- `record_step("process_data")` after the builder call
|
||||||
|
- `return render_template("X.html", **view_model)`
|
||||||
|
|
||||||
|
Each handler **drops** all the per-row computation, totals
|
||||||
|
formatting, credits/debts sorting, `raw_payments_by_person` building,
|
||||||
|
and `import json` lines.
|
||||||
|
|
||||||
|
Target post-refactor LOC for each route handler: ~25–30 lines
|
||||||
|
(currently 167 / 205 / 35).
|
||||||
|
|
||||||
|
### 3. Run the existing test suite + manual smoke
|
||||||
|
|
||||||
|
```
|
||||||
|
make test # all tests green
|
||||||
|
make web-py # browse /adults /juniors /payments visually
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- [app.py](app.py) — routes shrink dramatically; `get_month_labels`
|
||||||
|
and `group_payments_by_person` get moved out.
|
||||||
|
- New: `scripts/views.py` — three builders + the two helpers.
|
||||||
|
- [tests/test_app.py](tests/test_app.py) — unchanged; serves as the
|
||||||
|
regression guard.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make test` — all four `TestWebApp` tests pass unchanged.
|
||||||
|
2. `make web-py` and visit `/adults`, `/juniors`, `/payments` — each
|
||||||
|
renders identically to before (same table contents, same totals,
|
||||||
|
same credits/debts, same `?` rendering on juniors).
|
||||||
|
3. `git diff app.py` shows substantial deletions (route bodies
|
||||||
|
shrink) and only thin glue calling the new builders.
|
||||||
|
4. Optional sanity check: temporarily add `print(repr(view_model))`
|
||||||
|
in the route before `render_template` on `main` and on the branch,
|
||||||
|
diff for one fixture run — should be byte-identical dicts.
|
||||||
|
|
||||||
|
## Out of scope (future iterations, not this plan)
|
||||||
|
|
||||||
|
- `/api/<route>` shadow endpoints in Flask
|
||||||
|
- Go `internal/web/api/` DTOs and JSON Schemas
|
||||||
|
- Go `internal/services/membership/views.go` aggregators
|
||||||
|
- Go HTTP handlers for `/api/X`
|
||||||
|
- `cmd/parity/main.go` and `make parity` target
|
||||||
|
- Junior breakdown sidecar work in
|
||||||
|
`go/internal/services/membership/sources.go`
|
||||||
|
|
||||||
|
These are the original M5 tasks (M5.1–M5.4 in the progress tracker).
|
||||||
|
They become much easier once today's refactor lands, because the
|
||||||
|
shadow `/api/X` routes will be one-liners over the new builders.
|
||||||
445
scripts/views.py
Normal file
445
scripts/views.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from attendance import ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||||
|
from match_payments import canonical_member_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_labels(sorted_months, merged_months):
|
||||||
|
labels = {}
|
||||||
|
for m in sorted_months:
|
||||||
|
dt = datetime.strptime(m, "%Y-%m")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def group_payments_by_person(transactions, member_names=None):
|
||||||
|
canonical_by_key = (
|
||||||
|
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
||||||
|
)
|
||||||
|
grouped = {}
|
||||||
|
for tx in transactions:
|
||||||
|
person = str(tx.get("person", "")).strip()
|
||||||
|
if not person:
|
||||||
|
continue
|
||||||
|
for p in person.split(","):
|
||||||
|
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
key = canonical_by_key.get(canonical_member_key(p), p)
|
||||||
|
grouped.setdefault(key, []).append(tx)
|
||||||
|
for rows in grouped.values():
|
||||||
|
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_junior_members(junior_members):
|
||||||
|
"""Convert 4-tuple junior fee data to (fee, total_count) for reconcile."""
|
||||||
|
adapted = []
|
||||||
|
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.append((name, tier, adapted_fees))
|
||||||
|
return adapted
|
||||||
|
|
||||||
|
|
||||||
|
def build_adults_view_model(
|
||||||
|
members,
|
||||||
|
sorted_months,
|
||||||
|
result,
|
||||||
|
transactions,
|
||||||
|
current_month,
|
||||||
|
*,
|
||||||
|
attendance_url,
|
||||||
|
payments_url,
|
||||||
|
bank_account,
|
||||||
|
):
|
||||||
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in adult_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {
|
||||||
|
"name": name,
|
||||||
|
"months": [],
|
||||||
|
"balance": data["total_balance"],
|
||||||
|
"unpaid_periods": "",
|
||||||
|
"raw_unpaid_periods": "",
|
||||||
|
}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {
|
||||||
|
"expected": 0, "original_expected": 0,
|
||||||
|
"attendance_count": 0, "paid": 0, "exception": None,
|
||||||
|
})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = (
|
||||||
|
f"{override_amount} ({original_expected}) CZK ({count})"
|
||||||
|
if count > 0
|
||||||
|
else f"{override_amount} ({original_expected}) CZK"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = (
|
||||||
|
f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0:
|
||||||
|
amount_to_pay = max(0, expected - paid)
|
||||||
|
if paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
else:
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0 or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip,
|
||||||
|
})
|
||||||
|
|
||||||
|
settled_balance = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
|
|
||||||
|
payable_amount = max(0, -settled_balance)
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _settled_balance(name):
|
||||||
|
data = result["members"][name]
|
||||||
|
total = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
total += int(mdata.get("paid", 0)) - exp
|
||||||
|
return total
|
||||||
|
|
||||||
|
credits = sorted(
|
||||||
|
[{"name": n, "amount": _settled_balance(n)} for n in adult_names if _settled_balance(n) > 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
debts = sorted(
|
||||||
|
[{"name": n, "amount": abs(_settled_balance(n))} for n in adult_names if _settled_balance(n) < 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_payments_by_person = group_payments_by_person(
|
||||||
|
transactions, [name for name, _, _ in members]
|
||||||
|
)
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=result["unmatched"],
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=bank_account,
|
||||||
|
current_month=current_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_juniors_view_model(
|
||||||
|
junior_members,
|
||||||
|
adapted_members,
|
||||||
|
sorted_months,
|
||||||
|
result,
|
||||||
|
transactions,
|
||||||
|
current_month,
|
||||||
|
*,
|
||||||
|
attendance_url,
|
||||||
|
payments_url,
|
||||||
|
bank_account,
|
||||||
|
):
|
||||||
|
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||||
|
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||||
|
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in junior_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {
|
||||||
|
"name": name,
|
||||||
|
"months": [],
|
||||||
|
"balance": data["total_balance"],
|
||||||
|
"unpaid_periods": "",
|
||||||
|
"raw_unpaid_periods": "",
|
||||||
|
}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {
|
||||||
|
"expected": 0, "original_expected": 0,
|
||||||
|
"attendance_count": 0, "paid": 0, "exception": None,
|
||||||
|
})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
if expected != "?" and isinstance(expected, int):
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||||
|
adult_count = 0
|
||||||
|
junior_count = 0
|
||||||
|
if orig_fee_data and len(orig_fee_data) == 4:
|
||||||
|
_, _, adult_count, junior_count = orig_fee_data
|
||||||
|
|
||||||
|
breakdown = ""
|
||||||
|
if adult_count > 0 and junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J,{adult_count}A"
|
||||||
|
elif junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J"
|
||||||
|
elif adult_count > 0:
|
||||||
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
|
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK{count_str}"
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
|
if expected == "?":
|
||||||
|
status = "empty"
|
||||||
|
cell_text = f"?{count_str}"
|
||||||
|
elif paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
|
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip,
|
||||||
|
})
|
||||||
|
|
||||||
|
settled_balance = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
|
|
||||||
|
payable_amount = max(0, -settled_balance)
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
junior_all_names = [name for name, _, _ in adapted_members]
|
||||||
|
|
||||||
|
def _junior_settled_balance(name):
|
||||||
|
data = result["members"][name]
|
||||||
|
total = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
total += int(mdata.get("paid", 0)) - exp
|
||||||
|
return total
|
||||||
|
|
||||||
|
credits = sorted(
|
||||||
|
[{"name": n, "amount": _junior_settled_balance(n)} for n in junior_all_names if _junior_settled_balance(n) > 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
debts = sorted(
|
||||||
|
[{"name": n, "amount": abs(_junior_settled_balance(n))} for n in junior_all_names if _junior_settled_balance(n) < 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_payments_by_person = group_payments_by_person(
|
||||||
|
transactions, [name for name, _, _ in adapted_members]
|
||||||
|
)
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=result["unmatched"],
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=bank_account,
|
||||||
|
current_month=current_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payments_view_model(transactions, member_names, *, attendance_url, payments_url):
|
||||||
|
grouped = group_payments_by_person(transactions, member_names)
|
||||||
|
for tx in transactions:
|
||||||
|
if not str(tx.get("person", "")).strip():
|
||||||
|
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
||||||
|
for rows in grouped.values():
|
||||||
|
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||||
|
sorted_people = sorted(grouped.keys())
|
||||||
|
return dict(
|
||||||
|
grouped_payments=grouped,
|
||||||
|
sorted_people=sorted_people,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user