feat: initial dashboard implementation and robust attendance parsing
- Added a Makefile to easily run project scripts (fees, match, web, image) - Modified attendance.py to dynamically handle a variable number of header rows from the Google Sheet - Updated both attendance calculations and calculate_fees terminal output to show actual attendance counts (e.g., '750 CZK (3)') - Created a Flask web dashboard (app.py and templates/fees.html) to view member fees in an attractive, condensed, terminal-like UI - Bound the Flask server to port 5000 and added a routing alias from '/' to '/fees' - Configured Python virtual environment (.venv) creation directly into the Makefile to resolve global pip install errors on macOS Co-authored-by: Antigravity <antigravity@deepmind.com>
This commit is contained in:
35
.gitea/workflows/build.yaml
Normal file
35
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
TAG=${{ github.ref_name }}
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG=${{ inputs.tag }}
|
||||||
|
fi
|
||||||
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||||
|
docker build -f build/Dockerfile -t $IMAGE .
|
||||||
|
docker push $IMAGE
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# python cache
|
||||||
|
**/*.pyc
|
||||||
27
CLAUDE.md
Normal file
27
CLAUDE.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club.
|
||||||
|
|
||||||
|
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
|
||||||
|
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||||
|
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
This project uses a hybrid workflow:
|
||||||
|
- Claude.ai chat for brainstorming and design exploration
|
||||||
|
- Claude Code for implementation
|
||||||
|
|
||||||
|
## When Code Exists
|
||||||
|
|
||||||
|
Once a tech stack is chosen and implementation begins, update this file with:
|
||||||
|
- Build, test, and lint commands
|
||||||
|
- Architecture overview
|
||||||
|
- Development setup instructions
|
||||||
28
Makefile
Normal file
28
Makefile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: help fees match web image
|
||||||
|
|
||||||
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
|
VENV := .venv
|
||||||
|
PYTHON := $(VENV)/bin/python3
|
||||||
|
|
||||||
|
$(PYTHON):
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(PYTHON) -m pip install -q flask
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
|
@echo " make web - Start a dynamic web dashboard locally"
|
||||||
|
@echo " make image - Build an OCI container image"
|
||||||
|
|
||||||
|
fees: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/calculate_fees.py
|
||||||
|
|
||||||
|
match: $(PYTHON)
|
||||||
|
$(PYTHON) scripts/match_payments.py
|
||||||
|
|
||||||
|
web: $(PYTHON)
|
||||||
|
$(PYTHON) app.py
|
||||||
|
|
||||||
|
image:
|
||||||
|
docker build -t fuj-management:latest -f build/Dockerfile .
|
||||||
53
app.py
Normal file
53
app.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, render_template
|
||||||
|
|
||||||
|
# Add scripts directory to path to allow importing from it
|
||||||
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
|
from attendance import get_members_with_fees
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
# Redirect root to /fees for convenience while there are no other apps
|
||||||
|
return '<meta http-equiv="refresh" content="0; url=/fees" />'
|
||||||
|
|
||||||
|
@app.route("/fees")
|
||||||
|
def fees():
|
||||||
|
members, sorted_months = get_members_with_fees()
|
||||||
|
if not members:
|
||||||
|
return "No data."
|
||||||
|
|
||||||
|
# Filter to adults only for display
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
monthly_totals = {m: 0 for m in sorted_months}
|
||||||
|
|
||||||
|
formatted_results = []
|
||||||
|
for name, month_fees in results:
|
||||||
|
row = {"name": name, "months": []}
|
||||||
|
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)
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"fees.html",
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
results=formatted_results,
|
||||||
|
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months]
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, port=5001)
|
||||||
6
build/.dockerignore
Normal file
6
build/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.claude
|
||||||
|
.gemini
|
||||||
25
build/Dockerfile
Normal file
25
build/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash tzdata \
|
||||||
|
&& cp /usr/share/zoneinfo/Europe/Prague /etc/localtime \
|
||||||
|
&& echo "Europe/Prague" > /etc/timezone
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir flask
|
||||||
|
|
||||||
|
COPY app.py Makefile ./
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
|
COPY build/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \
|
||||||
|
CMD wget -q -O /dev/null http://localhost:5001/ || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
8
build/entrypoint.sh
Executable file
8
build/entrypoint.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[entrypoint] Starting Flask app on port 5001..."
|
||||||
|
|
||||||
|
# Running the app directly via python
|
||||||
|
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now.
|
||||||
|
exec python3 /app/app.py
|
||||||
69
docs/fee-calculation-spec.md
Normal file
69
docs/fee-calculation-spec.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Fee Calculation Spec — Tuesday Practices
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
- Google Sheet: `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA`
|
||||||
|
- Sheet: first sheet only (Tuesday practices, 20:30–22:00)
|
||||||
|
- Public export URL (CSV): `https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/export?format=csv`
|
||||||
|
|
||||||
|
## Sheet Structure
|
||||||
|
|
||||||
|
| Row | Content |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | Header: title in col A, dates in cols D+ (format `M/D/YYYY`) |
|
||||||
|
| 2 | Venue per date (irrelevant for pricing) |
|
||||||
|
| 3 | Total attendees per date |
|
||||||
|
| 4+ | Member rows: Name (col A), Tier (col B), Total (col C), attendance TRUE/FALSE (cols D+) |
|
||||||
|
|
||||||
|
Member rows end when the Name column is empty.
|
||||||
|
|
||||||
|
## Tiers
|
||||||
|
|
||||||
|
| Code | Meaning | Pays from this sheet? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | Adult | Yes |
|
||||||
|
| J | Junior | No (paid via separate attendance sheet) |
|
||||||
|
| X | Exempt | No |
|
||||||
|
|
||||||
|
## Fee Rules (Adults only)
|
||||||
|
|
||||||
|
Fees are calculated per calendar month based on the number of attended practices in that month.
|
||||||
|
|
||||||
|
| Practices in month | Fee |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | 0 CZK |
|
||||||
|
| 1 | 200 CZK |
|
||||||
|
| 2 or more | 750 CZK |
|
||||||
|
|
||||||
|
## Payment Matching
|
||||||
|
|
||||||
|
### Bank Account
|
||||||
|
|
||||||
|
- Fio banka transparent account: `2800359168/2010`
|
||||||
|
- Owner: Nathan Heilmann
|
||||||
|
- Public view: `https://ib.fio.cz/ib/transparent?a=2800359168`
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
|
||||||
|
- **Without API token**: scrape the public transparent account HTML page
|
||||||
|
- **With API token**: Fio REST API at `https://fioapi.fio.cz/v1/rest/periods/{token}/{from}/{to}/transactions.json`
|
||||||
|
- Token is generated in Fio internetbanking (Settings → API)
|
||||||
|
- Rate limit: 1 request per 30 seconds per token
|
||||||
|
- Available fields: date, amount, currency, sender account, sender name, VS, SS, KS, user identification, message, type, and more
|
||||||
|
|
||||||
|
### Matching Approach
|
||||||
|
|
||||||
|
Payments are matched to members using best-effort heuristics, with uncertain matches flagged for manual review.
|
||||||
|
|
||||||
|
1. **Name matching**: Normalize (strip diacritics, lowercase) sender name and message text, compare against member names and nicknames
|
||||||
|
2. **Month parsing**: Extract Czech month names (leden, únor, ...) and numeric patterns (01/26, 1/2026) from the message
|
||||||
|
3. **Amount validation**: Check if amount aligns with expected fees (200, 750, or multiples)
|
||||||
|
4. **Multi-person splitting**: When a message references multiple members, split the payment across them
|
||||||
|
|
||||||
|
### Advance Payments
|
||||||
|
|
||||||
|
If a payment references a month with no attendance data yet, it is tracked as **credit** on the member's account. The credit is applied once that month's attendance is recorded.
|
||||||
|
|
||||||
|
## PII Constraint
|
||||||
|
|
||||||
|
No member names or personal data are committed to git. All data is fetched at runtime from the Google Sheet and bank account.
|
||||||
61
docs/project-notes.md
Normal file
61
docs/project-notes.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Sports Club Financial Management — Project Notes
|
||||||
|
|
||||||
|
> **Context for Claude Code:** This document captures an ongoing brainstorming session
|
||||||
|
> started in Claude.ai chat. The owner is an experienced SRE/programmer. We are still
|
||||||
|
> in the discovery/design phase — no code has been written yet. Next steps: fill in
|
||||||
|
> current state (Section 3), then move into incremental automation design and implementation.
|
||||||
|
|
||||||
|
## 1. Project Goal
|
||||||
|
|
||||||
|
Design and incrementally automate financial and operational management for a small sports club.
|
||||||
|
|
||||||
|
## 2. Domain Entities (Draft)
|
||||||
|
|
||||||
|
- **Members** — roster, roles (player/coach/parent), contact info, membership status
|
||||||
|
- **Fees** — recurring (monthly/seasonal), one-off (tournament entry), per-member or per-family
|
||||||
|
- **Attendance** — practice sessions, matches, tournaments; who showed up
|
||||||
|
- **Expenses** — facility rental, equipment, travel, referee fees, insurance
|
||||||
|
- **Ledger** — income (fees, sponsors, fundraising) vs. expenses; balance tracking
|
||||||
|
|
||||||
|
## 3. Current State
|
||||||
|
|
||||||
|
_TODO: To be filled — critical input needed before design/implementation._
|
||||||
|
|
||||||
|
- Club size (members, teams):
|
||||||
|
- Current tooling (spreadsheets? paper? existing app?):
|
||||||
|
- System users (besides owner):
|
||||||
|
- Biggest pain point / what to solve first:
|
||||||
|
|
||||||
|
## 4. Automation Candidates (by estimated ROI)
|
||||||
|
|
||||||
|
1. Fee billing & payment tracking (reminders, status per member)
|
||||||
|
2. Attendance logging (check-in mechanism)
|
||||||
|
3. Expense categorization & reporting (monthly summaries, budget vs. actual)
|
||||||
|
4. Tournament management (signup, fee collection, travel)
|
||||||
|
|
||||||
|
## 5. Tech Considerations
|
||||||
|
|
||||||
|
- Who operates / interacts with the system?
|
||||||
|
- Complexity spectrum: Spreadsheet + Apps Script → lightweight web app → full platform
|
||||||
|
- Integration points: Slack, Google Forms, payment gateways, etc.
|
||||||
|
- **PII caution:** member data (names, emails, payment info) must stay out of git from day one. Enforce config/data separation early.
|
||||||
|
|
||||||
|
## 6. Suggested Approach
|
||||||
|
|
||||||
|
1. **Map domain** — finalize entities and workflows (Section 2 & 3)
|
||||||
|
2. **Identify pain points** — what's the worst manual step today?
|
||||||
|
3. **Design automation incrementally** — start with highest-ROI item
|
||||||
|
4. **Build** — iterate in this repo
|
||||||
|
|
||||||
|
## 7. Open Questions
|
||||||
|
|
||||||
|
- All items in Section 3 are unresolved.
|
||||||
|
- Tech stack TBD — depends on who the users are and complexity needs.
|
||||||
|
|
||||||
|
## 8. Decision Log
|
||||||
|
|
||||||
|
| Date | Decision | Rationale |
|
||||||
|
|------------|---------------------------------------|--------------------------------------------------------------|
|
||||||
|
| 2025-02-11 | Store project docs in git repo | Markdown-native, versioned, natural evolution toward code |
|
||||||
|
| 2025-02-11 | Hybrid workflow: chat → Claude Code | Chat better for brainstorming; Claude Code for building |
|
||||||
|
| 2025-02-11 | PII stays out of repo from day one | Avoid retrofitting data separation later |
|
||||||
79
docs/scripts.md
Normal file
79
docs/scripts.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Scripts
|
||||||
|
|
||||||
|
All scripts live in `scripts/` and use Python 3.10+ with stdlib only (no pip dependencies).
|
||||||
|
|
||||||
|
## calculate_fees.py
|
||||||
|
|
||||||
|
Calculates monthly fees for each Adult member based on Tuesday practice attendance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts && python3 calculate_fees.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs a table of Adult members with their monthly fee (0 / 200 / 750 CZK) and totals per month. Data is fetched live from the Google Sheet.
|
||||||
|
|
||||||
|
## match_payments.py
|
||||||
|
|
||||||
|
Matches incoming bank payments against expected fees to produce a reconciliation report.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts && python3 match_payments.py [--from YYYY-MM-DD] [--to YYYY-MM-DD]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--from` | `2025-12-01` | Start of date range for bank transactions |
|
||||||
|
| `--to` | today | End of date range |
|
||||||
|
|
||||||
|
**Bank data access** is controlled by the `FIO_API_TOKEN` environment variable:
|
||||||
|
|
||||||
|
- **Set** — uses the Fio REST API (JSON, structured data, all fields)
|
||||||
|
- **Not set** — scrapes the public transparent account HTML page
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With API token:
|
||||||
|
FIO_API_TOKEN=xxx python3 match_payments.py --from 2026-01-01 --to 2026-02-11
|
||||||
|
|
||||||
|
# Without (public page):
|
||||||
|
python3 match_payments.py --from 2026-01-01 --to 2026-02-11
|
||||||
|
```
|
||||||
|
|
||||||
|
**Report sections:**
|
||||||
|
|
||||||
|
1. **Summary table** — per member, per month: `OK` / `UNPAID {amount}` / `{paid}/{expected}` + balance
|
||||||
|
2. **Credits** — advance payments for months without attendance data yet
|
||||||
|
3. **Unmatched transactions** — payments the script couldn't assign to any member
|
||||||
|
4. **Matched transaction details** — full breakdown of which payment was assigned where, with `[REVIEW]` tags on low-confidence matches
|
||||||
|
|
||||||
|
**Known limitations:**
|
||||||
|
|
||||||
|
- Lump-sum payments covering multiple months are split evenly rather than by actual per-month fee
|
||||||
|
- Messages with no member name and a sender not in the member list cannot be matched
|
||||||
|
- Common surnames (Novák) are excluded from last-name-only matching to avoid false positives
|
||||||
|
|
||||||
|
## Shared modules
|
||||||
|
|
||||||
|
### attendance.py
|
||||||
|
|
||||||
|
Shared attendance and fee logic, imported by both scripts above.
|
||||||
|
|
||||||
|
Key functions:
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `fetch_csv()` | Fetches the Google Sheet as parsed CSV rows |
|
||||||
|
| `parse_dates(header_row)` | Extracts `(column_index, date)` pairs from the header |
|
||||||
|
| `group_by_month(dates)` | Groups column indices by `YYYY-MM` |
|
||||||
|
| `calculate_fee(count)` | Applies fee rules: 0→0, 1→200, 2+→750 CZK |
|
||||||
|
| `get_members(rows)` | Parses member rows into `(name, tier, row)` tuples |
|
||||||
|
| `get_members_with_fees()` | Full pipeline: fetch → parse → compute fees. Returns `(members, sorted_months)` |
|
||||||
|
|
||||||
|
### czech_utils.py
|
||||||
|
|
||||||
|
Czech language text utilities.
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `normalize(text)` | Strip diacritics and lowercase (`Štrúdl` → `strudl`) |
|
||||||
|
| `parse_month_references(text)` | Extract `YYYY-MM` strings from Czech free text. Handles month names in all declensions (`leden`, `ledna`, `lednu`), numeric formats (`01/26`, `11+12/2025`), dot notation (`12.2025`), and ranges (`listopad-leden`) |
|
||||||
|
| `CZECH_MONTHS` | Dict mapping normalized Czech month names (all declensions) to month numbers |
|
||||||
107
scripts/attendance.py
Normal file
107
scripts/attendance.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Shared attendance/fee logic for FUJ Tuesday practices."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||||
|
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
|
||||||
|
|
||||||
|
FEE_FULL = 750 # CZK, for 2+ practices in a month
|
||||||
|
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||||
|
|
||||||
|
COL_NAME = 0
|
||||||
|
COL_TIER = 1
|
||||||
|
FIRST_DATE_COL = 3
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_csv() -> list[list[str]]:
|
||||||
|
"""Fetch the attendance Google Sheet as parsed CSV rows."""
|
||||||
|
req = urllib.request.Request(EXPORT_URL)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
text = resp.read().decode("utf-8")
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
return list(reader)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
|
||||||
|
"""Return (column_index, date) pairs for all date columns."""
|
||||||
|
dates = []
|
||||||
|
for i in range(FIRST_DATE_COL, len(header_row)):
|
||||||
|
raw = header_row[i].strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dates.append((i, datetime.strptime(raw, "%m/%d/%Y")))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return dates
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
|
||||||
|
"""Group column indices by YYYY-MM."""
|
||||||
|
months: dict[str, list[int]] = {}
|
||||||
|
for col, dt in dates:
|
||||||
|
key = dt.strftime("%Y-%m")
|
||||||
|
months.setdefault(key, []).append(col)
|
||||||
|
return months
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_fee(attendance_count: int) -> int:
|
||||||
|
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → 750."""
|
||||||
|
if attendance_count == 0:
|
||||||
|
return 0
|
||||||
|
if attendance_count == 1:
|
||||||
|
return FEE_SINGLE
|
||||||
|
return FEE_FULL
|
||||||
|
|
||||||
|
|
||||||
|
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
|
||||||
|
"""Parse member rows. Returns list of (name, tier, row)."""
|
||||||
|
members = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
|
||||||
|
if not name or name.lower() in ("jméno", "name", "jmeno"):
|
||||||
|
continue
|
||||||
|
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
|
||||||
|
members.append((name, tier, row))
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]:
|
||||||
|
"""Fetch attendance data and compute fees.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(members, sorted_months) where members is a list of
|
||||||
|
(name, tier, {month_key: (fee, count)}) for ALL members (all tiers).
|
||||||
|
sorted_months is the list of YYYY-MM keys in order.
|
||||||
|
"""
|
||||||
|
rows = fetch_csv()
|
||||||
|
if len(rows) < 2:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
header_row = rows[0]
|
||||||
|
dates = parse_dates(header_row)
|
||||||
|
if not dates:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
months = group_by_month(dates)
|
||||||
|
sorted_months = sorted(months.keys())
|
||||||
|
members_raw = get_members(rows)
|
||||||
|
|
||||||
|
members = []
|
||||||
|
for name, tier, row in members_raw:
|
||||||
|
month_fees = {}
|
||||||
|
for month_key in sorted_months:
|
||||||
|
cols = months[month_key]
|
||||||
|
count = sum(
|
||||||
|
1
|
||||||
|
for c in cols
|
||||||
|
if c < len(row) and row[c].strip().upper() == "TRUE"
|
||||||
|
)
|
||||||
|
fee = calculate_fee(count) if tier == "A" else 0
|
||||||
|
month_fees[month_key] = (fee, count)
|
||||||
|
members.append((name, tier, month_fees))
|
||||||
|
|
||||||
|
return members, sorted_months
|
||||||
53
scripts/calculate_fees.py
Normal file
53
scripts/calculate_fees.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Calculate monthly fees from the FUJ Tuesday practice attendance sheet."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from attendance import get_members_with_fees
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
members, sorted_months = get_members_with_fees()
|
||||||
|
if not members:
|
||||||
|
print("No data.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter to adults only for display
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print table
|
||||||
|
name_width = max((len(r[0]) for r in results), default=20)
|
||||||
|
col_width = 15
|
||||||
|
|
||||||
|
header = f"{'Member':<{name_width}}"
|
||||||
|
for m in sorted_months:
|
||||||
|
header += f" | {month_labels[m]:>{col_width}}"
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
|
||||||
|
monthly_totals = {m: 0 for m in sorted_months}
|
||||||
|
for name, month_fees in results:
|
||||||
|
line = f"{name:<{name_width}}"
|
||||||
|
for m in sorted_months:
|
||||||
|
fee, count = month_fees[m]
|
||||||
|
monthly_totals[m] += fee
|
||||||
|
cell = f"{fee} CZK ({count})" if count > 0 else "-"
|
||||||
|
line += f" | {cell:>{col_width}}"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
# Totals row
|
||||||
|
print("-" * len(header))
|
||||||
|
totals_line = f"{'TOTAL':<{name_width}}"
|
||||||
|
for m in sorted_months:
|
||||||
|
cell = f"{monthly_totals[m]} CZK"
|
||||||
|
totals_line += f" | {cell:>{col_width}}"
|
||||||
|
print(totals_line)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
101
scripts/czech_utils.py
Normal file
101
scripts/czech_utils.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Czech text utilities — diacritics normalization and month parsing."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
CZECH_MONTHS = {
|
||||||
|
"leden": 1, "ledna": 1, "lednu": 1,
|
||||||
|
"unor": 2, "unora": 2, "unoru": 2,
|
||||||
|
"brezen": 3, "brezna": 3, "breznu": 3,
|
||||||
|
"duben": 4, "dubna": 4, "dubnu": 4,
|
||||||
|
"kveten": 5, "kvetna": 5, "kvetnu": 5,
|
||||||
|
"cerven": 6, "cervna": 6, "cervnu": 6,
|
||||||
|
"cervenec": 7, "cervnce": 7, "cervenci": 7,
|
||||||
|
"srpen": 8, "srpna": 8, "srpnu": 8,
|
||||||
|
"zari": 9,
|
||||||
|
"rijen": 10, "rijna": 10, "rijnu": 10,
|
||||||
|
"listopad": 11, "listopadu": 11,
|
||||||
|
"prosinec": 12, "prosince": 12, "prosinci": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""Strip diacritics and lowercase."""
|
||||||
|
nfkd = unicodedata.normalize("NFKD", text)
|
||||||
|
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_month_references(text: str, default_year: int = 2026) -> list[str]:
|
||||||
|
"""Extract YYYY-MM month references from Czech free text.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Czech month names: "leden", "únor", "prosinec" (all declensions)
|
||||||
|
- Numeric: "01/26", "1/2026", "11+12/2025"
|
||||||
|
- Ranges: "listopad-leden" (November through January)
|
||||||
|
- Slash-separated numeric months: "11+12/2025"
|
||||||
|
|
||||||
|
Returns sorted list of unique YYYY-MM strings.
|
||||||
|
"""
|
||||||
|
normalized = normalize(text)
|
||||||
|
results: set[str] = set()
|
||||||
|
|
||||||
|
# Pattern: numeric months with year, e.g. "11+12/2025", "01/26", "1/2026"
|
||||||
|
# Match groups of digits separated by + before a /year
|
||||||
|
numeric_pattern = re.findall(
|
||||||
|
r"([\d+]+)\s*/\s*(\d{2,4})", normalized
|
||||||
|
)
|
||||||
|
for months_part, year_str in numeric_pattern:
|
||||||
|
year = int(year_str)
|
||||||
|
if year < 100:
|
||||||
|
year += 2000
|
||||||
|
for m_str in months_part.split("+"):
|
||||||
|
m_str = m_str.strip()
|
||||||
|
if m_str.isdigit():
|
||||||
|
m = int(m_str)
|
||||||
|
if 1 <= m <= 12:
|
||||||
|
results.add(f"{year:04d}-{m:02d}")
|
||||||
|
|
||||||
|
# Pattern: standalone numeric month.year, e.g. "12.2025"
|
||||||
|
dot_pattern = re.findall(r"(\d{1,2})\s*\.\s*(\d{4})", normalized)
|
||||||
|
for m_str, year_str in dot_pattern:
|
||||||
|
m, year = int(m_str), int(year_str)
|
||||||
|
if 1 <= m <= 12:
|
||||||
|
results.add(f"{year:04d}-{m:02d}")
|
||||||
|
|
||||||
|
# Czech month names — handle ranges like "listopad-leden"
|
||||||
|
# First, find range patterns
|
||||||
|
month_name_re = "|".join(sorted(CZECH_MONTHS.keys(), key=len, reverse=True))
|
||||||
|
range_pattern = re.findall(
|
||||||
|
rf"({month_name_re})\s*-\s*({month_name_re})", normalized
|
||||||
|
)
|
||||||
|
found_in_ranges: set[str] = set()
|
||||||
|
for start_name, end_name in range_pattern:
|
||||||
|
found_in_ranges.add(start_name)
|
||||||
|
found_in_ranges.add(end_name)
|
||||||
|
start_m = CZECH_MONTHS[start_name]
|
||||||
|
end_m = CZECH_MONTHS[end_name]
|
||||||
|
# Walk from start to end, wrapping around December→January
|
||||||
|
m = start_m
|
||||||
|
while True:
|
||||||
|
year = default_year if m >= start_m and start_m > end_m else default_year
|
||||||
|
# If range wraps (e.g. Nov-Jan), months >= start are previous year
|
||||||
|
if start_m > end_m and m >= start_m:
|
||||||
|
year = default_year - 1
|
||||||
|
results.add(f"{year:04d}-{m:02d}")
|
||||||
|
if m == end_m:
|
||||||
|
break
|
||||||
|
m = m % 12 + 1
|
||||||
|
|
||||||
|
# Individual Czech month names (not already part of a range)
|
||||||
|
for match in re.finditer(rf"\b({month_name_re})\b", normalized):
|
||||||
|
name = match.group(1)
|
||||||
|
if name in found_in_ranges:
|
||||||
|
continue
|
||||||
|
m = CZECH_MONTHS[name]
|
||||||
|
# Heuristic: if month > 9 and we're early in the year, it's likely previous year
|
||||||
|
year = default_year
|
||||||
|
if m >= 10:
|
||||||
|
year = default_year - 1
|
||||||
|
results.add(f"{year:04d}-{m:02d}")
|
||||||
|
|
||||||
|
return sorted(results)
|
||||||
527
scripts/match_payments.py
Normal file
527
scripts/match_payments.py
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Match Fio bank payments against expected attendance fees."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
from attendance import get_members_with_fees
|
||||||
|
from czech_utils import normalize, parse_month_references
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transaction fetching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FioTableParser(HTMLParser):
|
||||||
|
"""Parse the second <table class="table"> on the Fio transparent page.
|
||||||
|
|
||||||
|
Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
|
||||||
|
Indices: 0 1 2 3 4 5 6 7 8
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._table_count = 0
|
||||||
|
self._in_target_table = False
|
||||||
|
self._in_thead = False
|
||||||
|
self._in_row = False
|
||||||
|
self._in_cell = False
|
||||||
|
self._current_row: list[str] = []
|
||||||
|
self._rows: list[list[str]] = []
|
||||||
|
self._cell_text = ""
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
cls = dict(attrs).get("class", "")
|
||||||
|
if tag == "table" and "table" in cls.split():
|
||||||
|
self._table_count += 1
|
||||||
|
if self._table_count == 2:
|
||||||
|
self._in_target_table = True
|
||||||
|
if self._in_target_table:
|
||||||
|
if tag == "thead":
|
||||||
|
self._in_thead = True
|
||||||
|
if tag == "tr" and not self._in_thead:
|
||||||
|
self._in_row = True
|
||||||
|
self._current_row = []
|
||||||
|
if self._in_row and tag in ("td", "th"):
|
||||||
|
self._in_cell = True
|
||||||
|
self._cell_text = ""
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if self._in_cell and tag in ("td", "th"):
|
||||||
|
self._in_cell = False
|
||||||
|
self._current_row.append(self._cell_text.strip())
|
||||||
|
if tag == "thead":
|
||||||
|
self._in_thead = False
|
||||||
|
if self._in_row and tag == "tr":
|
||||||
|
self._in_row = False
|
||||||
|
if self._current_row:
|
||||||
|
self._rows.append(self._current_row)
|
||||||
|
if tag == "table" and self._in_target_table:
|
||||||
|
self._in_target_table = False
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._in_cell:
|
||||||
|
self._cell_text += data
|
||||||
|
|
||||||
|
def get_rows(self) -> list[list[str]]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
# Fio transparent table column indices
|
||||||
|
_COL_DATE = 0
|
||||||
|
_COL_AMOUNT = 1
|
||||||
|
_COL_SENDER = 3
|
||||||
|
_COL_MESSAGE = 4
|
||||||
|
_COL_KS = 5
|
||||||
|
_COL_VS = 6
|
||||||
|
_COL_SS = 7
|
||||||
|
_COL_NOTE = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_czech_amount(s: str) -> float | None:
|
||||||
|
"""Parse '1 500,00 CZK' to float."""
|
||||||
|
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||||
|
s = re.sub(r"[A-Za-z]+", "", s).strip()
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_czech_date(s: str) -> str | None:
|
||||||
|
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
||||||
|
s = s.strip()
|
||||||
|
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_transactions_transparent(
|
||||||
|
date_from: str, date_to: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch transactions from Fio transparent account HTML page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_from: D.M.YYYY format
|
||||||
|
date_to: D.M.YYYY format
|
||||||
|
"""
|
||||||
|
url = (
|
||||||
|
f"https://ib.fio.cz/ib/transparent?a=2800359168"
|
||||||
|
f"&f={date_from}&t={date_to}"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
html = resp.read().decode("utf-8")
|
||||||
|
|
||||||
|
parser = _FioTableParser()
|
||||||
|
parser.feed(html)
|
||||||
|
rows = parser.get_rows()
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
for row in rows:
|
||||||
|
if len(row) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def col(i):
|
||||||
|
return row[i].strip() if i < len(row) else ""
|
||||||
|
|
||||||
|
date_str = _parse_czech_date(col(_COL_DATE))
|
||||||
|
amount = _parse_czech_amount(col(_COL_AMOUNT))
|
||||||
|
|
||||||
|
if date_str is None or amount is None or amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
transactions.append({
|
||||||
|
"date": date_str,
|
||||||
|
"amount": amount,
|
||||||
|
"sender": col(_COL_SENDER),
|
||||||
|
"message": col(_COL_MESSAGE),
|
||||||
|
"vs": col(_COL_VS),
|
||||||
|
})
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_transactions_api(
|
||||||
|
token: str, date_from: str, date_to: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch transactions via Fio REST API (JSON).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Fio API token
|
||||||
|
date_from: YYYY-MM-DD format
|
||||||
|
date_to: YYYY-MM-DD format
|
||||||
|
"""
|
||||||
|
url = (
|
||||||
|
f"https://fioapi.fio.cz/v1/rest/periods/{token}"
|
||||||
|
f"/{date_from}/{date_to}/transactions.json"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||||
|
for tx in (tx_list.get("transaction") or []):
|
||||||
|
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||||
|
def val(col_id):
|
||||||
|
col = tx.get(f"column{col_id}")
|
||||||
|
return col["value"] if col else ""
|
||||||
|
|
||||||
|
amount = float(val(1) or 0)
|
||||||
|
if amount <= 0:
|
||||||
|
continue # Skip outgoing
|
||||||
|
|
||||||
|
date_raw = val(0) or ""
|
||||||
|
# API returns date as "YYYY-MM-DD+HHMM" or ISO format
|
||||||
|
date_str = date_raw[:10] if date_raw else ""
|
||||||
|
|
||||||
|
transactions.append({
|
||||||
|
"date": date_str,
|
||||||
|
"amount": amount,
|
||||||
|
"sender": str(val(10) or ""), # column10 = sender name
|
||||||
|
"message": str(val(16) or ""), # column16 = message for recipient
|
||||||
|
"vs": str(val(5) or ""), # column5 = VS
|
||||||
|
"user_id": str(val(7) or ""), # column7 = user identification
|
||||||
|
"sender_account": str(val(2) or ""), # column2 = sender account
|
||||||
|
})
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
||||||
|
"""Fetch transactions, using API if token available, else transparent page."""
|
||||||
|
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||||
|
if token:
|
||||||
|
return fetch_transactions_api(token, date_from, date_to)
|
||||||
|
|
||||||
|
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
||||||
|
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
|
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
|
return fetch_transactions_transparent(
|
||||||
|
from_dt.strftime("%-d.%-m.%Y"),
|
||||||
|
to_dt.strftime("%-d.%-m.%Y"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Name matching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_name_variants(name: str) -> list[str]:
|
||||||
|
"""Build searchable name variants from a member name.
|
||||||
|
|
||||||
|
E.g. 'František Vrbík (Štrúdl)' → ['frantisek vrbik', 'strudl', 'vrbik']
|
||||||
|
"""
|
||||||
|
# Extract nickname from parentheses
|
||||||
|
nickname_match = re.search(r"\(([^)]+)\)", name)
|
||||||
|
nickname = nickname_match.group(1) if nickname_match else ""
|
||||||
|
|
||||||
|
# Base name without nickname
|
||||||
|
base = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
|
||||||
|
normalized_base = normalize(base)
|
||||||
|
normalized_nick = normalize(nickname)
|
||||||
|
|
||||||
|
variants = [normalized_base]
|
||||||
|
if normalized_nick:
|
||||||
|
variants.append(normalized_nick)
|
||||||
|
|
||||||
|
# Also add last name alone (for matching in messages)
|
||||||
|
parts = normalized_base.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
variants.append(parts[-1]) # last name
|
||||||
|
variants.append(parts[0]) # first name
|
||||||
|
|
||||||
|
return [v for v in variants if len(v) >= 3]
|
||||||
|
|
||||||
|
|
||||||
|
def match_members(
|
||||||
|
text: str, member_names: list[str]
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
"""Find members mentioned in text.
|
||||||
|
|
||||||
|
Returns list of (member_name, confidence) where confidence is 'auto' or 'review'.
|
||||||
|
"""
|
||||||
|
normalized_text = normalize(text)
|
||||||
|
matches = []
|
||||||
|
|
||||||
|
for name in member_names:
|
||||||
|
variants = _build_name_variants(name)
|
||||||
|
# Full name match = high confidence
|
||||||
|
full_name = variants[0] if variants else ""
|
||||||
|
if full_name and full_name in normalized_text:
|
||||||
|
matches.append((name, "auto"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Last name + first name both present = high confidence
|
||||||
|
parts = full_name.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
if parts[0] in normalized_text and parts[-1] in normalized_text:
|
||||||
|
matches.append((name, "auto"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Nickname match = high confidence
|
||||||
|
if len(variants) > 1 and variants[1] in normalized_text:
|
||||||
|
matches.append((name, "auto"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Last name only = lower confidence, but skip very common Czech surnames
|
||||||
|
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
|
||||||
|
if (
|
||||||
|
len(parts) >= 2
|
||||||
|
and len(parts[-1]) >= 4
|
||||||
|
and parts[-1] not in _COMMON_SURNAMES
|
||||||
|
and parts[-1] in normalized_text
|
||||||
|
):
|
||||||
|
matches.append((name, "review"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reconciliation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
members: list[tuple[str, str, dict[str, int]]],
|
||||||
|
sorted_months: list[str],
|
||||||
|
transactions: list[dict],
|
||||||
|
) -> dict:
|
||||||
|
"""Match transactions to members and months.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
- 'members': {name: {'tier': str, 'months': {YYYY-MM: {'expected': int, 'paid': int, 'transactions': list}}}}
|
||||||
|
- 'unmatched': list of transactions that couldn't be matched
|
||||||
|
- 'credits': {name: int} — excess payments tracked as credit
|
||||||
|
"""
|
||||||
|
member_names = [name for name, _, _ in members]
|
||||||
|
member_tiers = {name: tier for name, tier, _ in members}
|
||||||
|
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members}
|
||||||
|
|
||||||
|
# Initialize ledger
|
||||||
|
ledger: dict[str, dict[str, dict]] = {}
|
||||||
|
for name in member_names:
|
||||||
|
ledger[name] = {}
|
||||||
|
for m in sorted_months:
|
||||||
|
ledger[name][m] = {
|
||||||
|
"expected": member_fees[name].get(m, 0),
|
||||||
|
"paid": 0,
|
||||||
|
"transactions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatched = []
|
||||||
|
credits: dict[str, int] = {}
|
||||||
|
|
||||||
|
for tx in transactions:
|
||||||
|
# Combine sender + message for searching
|
||||||
|
search_text = f"{tx['sender']} {tx['message']} {tx.get('user_id', '')}"
|
||||||
|
matched_members = match_members(search_text, member_names)
|
||||||
|
matched_months = parse_month_references(
|
||||||
|
tx["message"] + " " + tx.get("user_id", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matched_members:
|
||||||
|
# Try matching sender name alone with more lenient matching
|
||||||
|
matched_members = match_members(tx["sender"], member_names)
|
||||||
|
|
||||||
|
if not matched_members:
|
||||||
|
unmatched.append(tx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not matched_months:
|
||||||
|
# If no month specified, try to infer from payment date
|
||||||
|
tx_date = tx["date"]
|
||||||
|
if tx_date:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(tx_date, "%Y-%m-%d")
|
||||||
|
# Assume payment is for the current month
|
||||||
|
matched_months = [dt.strftime("%Y-%m")]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not matched_months:
|
||||||
|
unmatched.append(tx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Allocate payment across matched members and months
|
||||||
|
num_allocations = len(matched_members) * len(matched_months)
|
||||||
|
per_allocation = tx["amount"] / num_allocations if num_allocations > 0 else 0
|
||||||
|
|
||||||
|
for member_name, confidence in matched_members:
|
||||||
|
for month_key in matched_months:
|
||||||
|
entry = {
|
||||||
|
"amount": per_allocation,
|
||||||
|
"date": tx["date"],
|
||||||
|
"sender": tx["sender"],
|
||||||
|
"message": tx["message"],
|
||||||
|
"confidence": confidence,
|
||||||
|
}
|
||||||
|
if month_key in ledger.get(member_name, {}):
|
||||||
|
ledger[member_name][month_key]["paid"] += per_allocation
|
||||||
|
ledger[member_name][month_key]["transactions"].append(entry)
|
||||||
|
else:
|
||||||
|
# Future month — track as credit
|
||||||
|
credits[member_name] = credits.get(member_name, 0) + int(per_allocation)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"members": {
|
||||||
|
name: {
|
||||||
|
"tier": member_tiers[name],
|
||||||
|
"months": ledger[name],
|
||||||
|
}
|
||||||
|
for name in member_names
|
||||||
|
},
|
||||||
|
"unmatched": unmatched,
|
||||||
|
"credits": credits,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def print_report(result: dict, sorted_months: list[str]):
|
||||||
|
month_labels = {
|
||||||
|
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Per-member breakdown (adults only) ---
|
||||||
|
print("=" * 80)
|
||||||
|
print("PAYMENT RECONCILIATION REPORT")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
adults = {
|
||||||
|
name: data
|
||||||
|
for name, data in result["members"].items()
|
||||||
|
if data["tier"] == "A"
|
||||||
|
}
|
||||||
|
|
||||||
|
total_expected = 0
|
||||||
|
total_paid = 0
|
||||||
|
|
||||||
|
# Summary table
|
||||||
|
name_width = max((len(n) for n in adults), default=20)
|
||||||
|
header = f"{'Member':<{name_width}}"
|
||||||
|
for m in sorted_months:
|
||||||
|
header += f" | {month_labels[m]:>10}"
|
||||||
|
header += " | {'Balance':>10}"
|
||||||
|
print(f"\n{'Member':<{name_width}}", end="")
|
||||||
|
for m in sorted_months:
|
||||||
|
print(f" | {month_labels[m]:>10}", end="")
|
||||||
|
print(f" | {'Balance':>10}")
|
||||||
|
print("-" * (name_width + (len(sorted_months) + 1) * 13))
|
||||||
|
|
||||||
|
for name in sorted(adults.keys()):
|
||||||
|
data = adults[name]
|
||||||
|
line = f"{name:<{name_width}}"
|
||||||
|
member_balance = 0
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
|
||||||
|
expected = mdata["expected"]
|
||||||
|
paid = int(mdata["paid"])
|
||||||
|
total_expected += expected
|
||||||
|
total_paid += paid
|
||||||
|
|
||||||
|
if expected == 0 and paid == 0:
|
||||||
|
cell = "-"
|
||||||
|
elif paid >= expected and expected > 0:
|
||||||
|
cell = "OK"
|
||||||
|
elif paid > 0:
|
||||||
|
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"
|
||||||
|
line += f" | {balance_str:>10}"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
print("-" * (name_width + (len(sorted_months) + 1) * 13))
|
||||||
|
print(f"{'TOTAL':<{name_width}}", end="")
|
||||||
|
for _ in sorted_months:
|
||||||
|
print(f" | {'':>10}", end="")
|
||||||
|
balance = total_paid - total_expected
|
||||||
|
print(f" | {f'Expected: {total_expected}, Paid: {int(total_paid)}, Balance: {balance:+d}'}")
|
||||||
|
|
||||||
|
# --- Credits ---
|
||||||
|
if result["credits"]:
|
||||||
|
print(f"\n{'CREDITS (advance payments for future months)':}")
|
||||||
|
for name, amount in sorted(result["credits"].items()):
|
||||||
|
print(f" {name}: {amount} CZK")
|
||||||
|
|
||||||
|
# --- Unmatched transactions ---
|
||||||
|
if result["unmatched"]:
|
||||||
|
print(f"\n{'UNMATCHED TRANSACTIONS (need manual review)':}")
|
||||||
|
print(f" {'Date':<12} {'Amount':>10} {'Sender':<30} {'Message'}")
|
||||||
|
print(f" {'-'*12} {'-'*10} {'-'*30} {'-'*30}")
|
||||||
|
for tx in result["unmatched"]:
|
||||||
|
print(
|
||||||
|
f" {tx['date']:<12} {tx['amount']:>10.0f} "
|
||||||
|
f"{tx['sender']:<30} {tx['message']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Detailed matched transactions ---
|
||||||
|
print(f"\n{'MATCHED TRANSACTION DETAILS':}")
|
||||||
|
for name in sorted(adults.keys()):
|
||||||
|
data = adults[name]
|
||||||
|
has_payments = any(
|
||||||
|
data["months"].get(m, {}).get("transactions")
|
||||||
|
for m in sorted_months
|
||||||
|
)
|
||||||
|
if not has_payments:
|
||||||
|
continue
|
||||||
|
print(f"\n {name}:")
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {})
|
||||||
|
for tx in mdata.get("transactions", []):
|
||||||
|
conf = " [REVIEW]" if tx["confidence"] == "review" else ""
|
||||||
|
print(
|
||||||
|
f" {month_labels[m]}: {tx['amount']:.0f} CZK "
|
||||||
|
f"from {tx['sender']} — \"{tx['message']}\"{conf}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Match bank payments against expected attendance fees."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--from", dest="date_from", default="2025-12-01",
|
||||||
|
help="Start date YYYY-MM-DD (default: 2025-12-01)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--to", dest="date_to",
|
||||||
|
default=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
help="End date YYYY-MM-DD (default: today)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"Fetching attendance data...")
|
||||||
|
members, sorted_months = get_members_with_fees()
|
||||||
|
if not members:
|
||||||
|
print("No attendance data found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Fetching transactions from {args.date_from} to {args.date_to}...")
|
||||||
|
transactions = fetch_transactions(args.date_from, args.date_to)
|
||||||
|
print(f"Found {len(transactions)} incoming transactions.\n")
|
||||||
|
|
||||||
|
result = reconcile(members, sorted_months, transactions)
|
||||||
|
print_report(result, sorted_months)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
141
templates/fees.html
Normal file
141
templates/fees.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ Fees Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
/* Deeper black */
|
||||||
|
color: #cccccc;
|
||||||
|
/* Base gray terminal text */
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
/* Even smaller font */
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
/* Terminal green */
|
||||||
|
font-family: inherit;
|
||||||
|
/* Use monospace for header too */
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
/* Remove the card background */
|
||||||
|
border: 1px solid #333;
|
||||||
|
/* Just a thin outline if needed, or none */
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
/* Extremely tight padding */
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
/* Dashed lines for terminal feel */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
/* Stronger border for header */
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
/* Very subtle hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #00ff00;
|
||||||
|
/* Highlight total row */
|
||||||
|
border-top: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: #444444;
|
||||||
|
/* Darker gray for empty cells */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-paid {
|
||||||
|
color: #aaaaaa;
|
||||||
|
/* Light gray for normal cells */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>FUJ Fees Dashboard</h1>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in results %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.name }}</td>
|
||||||
|
{% for cell in row.months %}
|
||||||
|
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total">
|
||||||
|
<td>TOTAL</td>
|
||||||
|
{% for t in totals %}
|
||||||
|
<td>{{ t }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user