Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
022facc957 | ||
|
|
96c14f0b22 | ||
|
|
6d7dbfa624 | ||
|
|
c00111cff1 | ||
|
|
d263d8a534 | ||
|
|
af030c8255 |
@@ -31,11 +31,11 @@ jobs:
|
|||||||
TAG=${{ inputs.tag }}
|
TAG=${{ inputs.tag }}
|
||||||
fi
|
fi
|
||||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||||
docker build -f build/Dockerfile \
|
docker build -f legacy/build/Dockerfile \
|
||||||
--build-arg GIT_TAG=$TAG \
|
--build-arg GIT_TAG=$TAG \
|
||||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-t $IMAGE .
|
-t $IMAGE legacy/
|
||||||
docker push $IMAGE
|
docker push $IMAGE
|
||||||
|
|
||||||
build-go:
|
build-go:
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install git, curl, ca-certificates
|
- name: Install git, curl, ca-certificates, jq
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y --no-install-recommends git curl ca-certificates
|
apt-get install -y --no-install-recommends git curl ca-certificates jq
|
||||||
|
|
||||||
- name: Install tea
|
- name: Install tea
|
||||||
run: |
|
run: |
|
||||||
@@ -62,12 +62,17 @@ jobs:
|
|||||||
id: resolve
|
id: resolve
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
GIT_TAG="${{ inputs.tag }}"
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
|
||||||
else
|
else
|
||||||
# workflow_run: use the ref name of the triggering workflow (the pushed git tag).
|
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
|
||||||
GIT_TAG="${{ github.event.workflow_run.head_branch }}"
|
# Look up the tag name that points to the triggering commit SHA via the API.
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
GIT_TAG=$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
|
||||||
|
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
|
||||||
fi
|
fi
|
||||||
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
|
|
||||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Resolved image: ${IMAGE}"
|
echo "Resolved image: ${IMAGE}"
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-3
@@ -2,14 +2,16 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python Debugger: Flask",
|
"name": "Python Debugger: Flask (legacy)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "flask",
|
"module": "flask",
|
||||||
"python": "${workspaceFolder}/.venv/bin/python",
|
"python": "${workspaceFolder}/legacy/.venv/bin/python",
|
||||||
|
"cwd": "${workspaceFolder}/legacy",
|
||||||
"env": {
|
"env": {
|
||||||
"FLASK_APP": "app.py",
|
"FLASK_APP": "app.py",
|
||||||
"FLASK_DEBUG": "1"
|
"FLASK_DEBUG": "1",
|
||||||
|
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
|
||||||
},
|
},
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-10-08 11:33 CEST — refactor: move Python implementation to legacy/, Go is primary
|
||||||
|
|
||||||
|
- Go (`go/`) is now the primary implementation. The original Python version moved to `legacy/` (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `uv.lock`, `build/`) and is considered legacy; new features are still built in both.
|
||||||
|
- `CLAUDE.md` rewritten for the Go-first layout, with a "Dual implementation rule" section.
|
||||||
|
- Makefile Python targets keep their names and point at `legacy/`; virtualenv is `legacy/.venv`, Python cache is `legacy/tmp/`; `CREDENTIALS_PATH` is exported pointing at the root `.secret/` file.
|
||||||
|
- CI builds the Python image with `legacy/` as context; the image no longer contains the Makefile. `legacy/.dockerignore` now takes effect and excludes nested `__pycache__`.
|
||||||
|
- Go comments, fixture README, capture script and VS Code launch config updated to the new paths.
|
||||||
|
|
||||||
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
|
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
|
||||||
|
|
||||||
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
|
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
|
||||||
|
|||||||
@@ -4,28 +4,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
|
||||||
|
|
||||||
|
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
|
||||||
|
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
|
||||||
|
|
||||||
|
## Dual implementation rule
|
||||||
|
|
||||||
|
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
|
||||||
|
|
||||||
|
1. Implement and test in Go first (`go/`).
|
||||||
|
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
|
||||||
|
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
|
||||||
|
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
|
||||||
|
|
||||||
|
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
|
||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
||||||
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
|
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
This project uses `uv` for dependency management.
|
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv venv && uv sync
|
make go-build # builds bin/fuj
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
|
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make venv # uv sync --project legacy → legacy/.venv
|
||||||
|
```
|
||||||
|
|
||||||
|
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
Go (primary):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make web # Start dashboard at http://localhost:5001
|
make web-go # Build and start dashboard at http://localhost:8080
|
||||||
|
make go-build # Build bin/fuj
|
||||||
|
make go-test # Unit tests (go test -race ./...)
|
||||||
|
make go-parity # Parity tests against the captured Python fixtures
|
||||||
|
make go-test-all # Both of the above
|
||||||
|
make go-lint # golangci-lint
|
||||||
|
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
|
||||||
|
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Python (legacy):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
|
||||||
make web-debug # Same with FLASK_DEBUG=1
|
make web-debug # Same with FLASK_DEBUG=1
|
||||||
make test # Run all tests (unittest discover)
|
make test # Run all tests (unittest discover)
|
||||||
make test-v # Tests with verbose output
|
make test-v # Tests with verbose output
|
||||||
@@ -33,12 +67,20 @@ make fees # Print fee report from attendance sheet
|
|||||||
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
||||||
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
||||||
make reconcile # Print balance report from Google Sheets data
|
make reconcile # Print balance report from Google Sheets data
|
||||||
make image # Build Docker image
|
make image # Build the legacy Python Docker image
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
|
||||||
|
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a single test:
|
Run a single test:
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
|
cd go && go test ./internal/domain/reconcile -run TestName
|
||||||
|
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -46,25 +88,45 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
|
|||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
|
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
|
||||||
Google Sheets (payments) ──► match_payments.py ──┘
|
Google Sheets (payments) ──► payments loader ──┘
|
||||||
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
|
Fio Bank API ──► bank sync ──► Google Sheets (payments)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key modules
|
### Repository layout
|
||||||
|
|
||||||
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
|
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
|
||||||
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
|
||||||
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
|
||||||
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`.
|
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
|
||||||
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
|
||||||
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
### Key modules — Go (primary)
|
||||||
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
|
|
||||||
|
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
|
||||||
|
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
|
||||||
|
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
|
||||||
|
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
|
||||||
|
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
|
||||||
|
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
|
||||||
|
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
|
||||||
|
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
|
||||||
|
|
||||||
|
### Key modules — Python (legacy)
|
||||||
|
|
||||||
|
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
|
||||||
|
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
||||||
|
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
||||||
|
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
|
||||||
|
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
||||||
|
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
||||||
|
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
|
||||||
|
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
|
||||||
|
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
|
||||||
|
|
||||||
### Member tiers
|
### Member tiers
|
||||||
|
|
||||||
Tiers are set in column B of the attendance sheet:
|
Tiers are set in column B of the attendance sheet:
|
||||||
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
||||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||||
|
|
||||||
@@ -76,17 +138,17 @@ Tiers are set in column B of the attendance sheet:
|
|||||||
|
|
||||||
### Merged months
|
### Merged months
|
||||||
|
|
||||||
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
`AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
|
||||||
|
|
||||||
### Caching
|
### Caching
|
||||||
|
|
||||||
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`.
|
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
|
||||||
|
|
||||||
### Payments sheet columns
|
### Payments sheet columns
|
||||||
|
|
||||||
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
||||||
|
|
||||||
`Person` and `Purpose` are written by `infer_payments.py` and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
|
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
|
||||||
|
|
||||||
### QR codes
|
### QR codes
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
|
||||||
VENV := .venv
|
LEGACY := legacy
|
||||||
PYTHON := $(VENV)/bin/python3
|
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
|
||||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
VENV := $(LEGACY)/.venv
|
||||||
|
PYTHON := $(CURDIR)/$(VENV)/bin/python3
|
||||||
|
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
|
||||||
|
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
|
||||||
|
export CREDENTIALS_PATH ?= $(CREDENTIALS)
|
||||||
|
|
||||||
GO_SRC := go
|
GO_SRC := go
|
||||||
GO_BIN := bin/fuj
|
GO_BIN := bin/fuj
|
||||||
|
|
||||||
$(PYTHON): .venv/.last_sync
|
$(PYTHON): $(VENV)/.last_sync
|
||||||
|
|
||||||
.venv/.last_sync: pyproject.toml
|
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
touch .venv/.last_sync
|
touch $(VENV)/.last_sync
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
|
@echo " make web - Start legacy Python dashboard (alias for web-py)"
|
||||||
@echo " make web-py - Start Python dashboard on :5001"
|
@echo " make web-py - Start legacy Python dashboard on :5001"
|
||||||
@echo " make web-go - Build and start Go dashboard on :8080"
|
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
|
||||||
@echo " make web-debug - Start Python dashboard in debug mode"
|
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||||
@echo " make go-build - Build Go binary to bin/fuj"
|
@echo " make go-build - Build Go binary to bin/fuj"
|
||||||
@echo " make go-test - Run Go unit tests"
|
@echo " make go-test - Run Go unit tests"
|
||||||
@@ -44,21 +48,21 @@ help:
|
|||||||
@echo " make docs - Serve documentation in a browser"
|
@echo " make docs - Serve documentation in a browser"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
|
|
||||||
fees: $(PYTHON)
|
fees: $(PYTHON)
|
||||||
$(PYTHON) scripts/calculate_fees.py
|
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
|
||||||
|
|
||||||
match: $(PYTHON)
|
match: $(PYTHON)
|
||||||
$(PYTHON) scripts/match_payments.py
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py
|
||||||
|
|
||||||
web: web-py
|
web: web-py
|
||||||
|
|
||||||
web-py: $(PYTHON)
|
web-py: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
web-debug: $(PYTHON)
|
web-debug: $(PYTHON)
|
||||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
go-build:
|
go-build:
|
||||||
cd $(GO_SRC) && go build -trimpath \
|
cd $(GO_SRC) && go build -trimpath \
|
||||||
@@ -82,10 +86,10 @@ capture-fixtures: $(PYTHON)
|
|||||||
infer_transaction_details format_date reconcile; do \
|
infer_transaction_details format_date reconcile; do \
|
||||||
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
||||||
mkdir -p "$$dir"; \
|
mkdir -p "$$dir"; \
|
||||||
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
|
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
|
||||||
| while IFS= read -r line; do \
|
| while IFS= read -r line; do \
|
||||||
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
||||||
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
||||||
done; \
|
done; \
|
||||||
echo " $$func done"; \
|
echo " $$func done"; \
|
||||||
done
|
done
|
||||||
@@ -112,34 +116,34 @@ image:
|
|||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
||||||
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-f build/Dockerfile .
|
-f $(LEGACY)/build/Dockerfile $(LEGACY)
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|
||||||
sync: $(PYTHON)
|
sync: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
sync-2025: $(PYTHON)
|
sync-2025: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2025-10-01 --to 2025-12-31 --sort-by-date
|
||||||
|
|
||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
|
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
|
||||||
|
|
||||||
infer: $(PYTHON)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
reconcile: ## Match payments against attendance
|
reconcile: $(PYTHON) ## Match payments against attendance
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
test: $(PYTHON) ## Run web application tests
|
test: $(PYTHON) ## Run web application tests
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
|
cd $(LEGACY) && $(PYTHON) -m unittest discover tests
|
||||||
|
|
||||||
test-v: $(PYTHON) ## Run tests with verbose output
|
test-v: $(PYTHON) ## Run tests with verbose output
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
|
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
|
||||||
|
|
||||||
docs: ## Serve documentation locally
|
docs: ## Serve documentation locally
|
||||||
@echo "Starting documentation server at http://localhost:8000"
|
@echo "Starting documentation server at http://localhost:8000"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
|
|||||||
|
|
||||||
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
|
||||||
|
|
||||||
|
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Use the sidebar to explore the documentation:
|
Use the sidebar to explore the documentation:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
|||||||
|
|
||||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||||
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||||
// template context only (app.py inject_render_time) and is logged via
|
// template context only (legacy/app.py inject_render_time) and is logged via
|
||||||
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||||
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||||
var defaultAllowlist = []string{"render_time.total"}
|
var defaultAllowlist = []string{"render_time.total"}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const (
|
|||||||
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP.
|
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
|
||||||
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
|
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
|
||||||
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
||||||
var CacheSheetMap = map[string]string{
|
var CacheSheetMap = map[string]string{
|
||||||
@@ -53,7 +53,7 @@ var CacheSheetMap = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config holds all runtime configuration loaded from environment variables.
|
// Config holds all runtime configuration loaded from environment variables.
|
||||||
// Mirrors scripts/config.py.
|
// Mirrors legacy/scripts/config.py.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CredentialsPath string
|
CredentialsPath string
|
||||||
QRAccount string // IBAN of the primary account used for QR codes
|
QRAccount string // IBAN of the primary account used for QR codes
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-05:
|
// All expected outputs verified against live Python implementation on 2026-05-05:
|
||||||
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
// PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package fees ports fee calculation from scripts/attendance.py.
|
// Package fees ports fee calculation from legacy/scripts/attendance.py.
|
||||||
package fees
|
package fees
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -6,7 +6,7 @@ const (
|
|||||||
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
|
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||||
// Months absent from this map fall back to AdultFeeDefault.
|
// Months absent from this map fall back to AdultFeeDefault.
|
||||||
var AdultFeeMonthlyRate = map[string]int{
|
var AdultFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package fees
|
|||||||
|
|
||||||
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
||||||
|
|
||||||
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
|
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
|
||||||
// Months absent from this map fall back to JuniorFeeDefault.
|
// Months absent from this map fall back to JuniorFeeDefault.
|
||||||
var JuniorFeeMonthlyRate = map[string]int{
|
var JuniorFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 250,
|
"2025-09": 250,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Package matching ports name/member matching from scripts/match_payments.py.
|
// Package matching ports name/member matching from legacy/scripts/match_payments.py.
|
||||||
package matching
|
package matching
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
|||||||
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
||||||
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py format_date.
|
// Ports legacy/scripts/match_payments.py format_date.
|
||||||
func FormatDate(val any) string {
|
func FormatDate(val any) string {
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import format_date
|
// from match_payments import format_date
|
||||||
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
||||||
// print(repr(format_date(v)))
|
// print(repr(format_date(v)))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type InferredDetails struct {
|
|||||||
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
||||||
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py infer_transaction_details.
|
// Ports legacy/scripts/match_payments.py infer_transaction_details.
|
||||||
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
||||||
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 << 'EOF'
|
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
|
||||||
// from match_payments import infer_transaction_details
|
// from match_payments import infer_transaction_details
|
||||||
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
|
|||||||
// MatchMembers finds members mentioned in text and returns them with a
|
// MatchMembers finds members mentioned in text and returns them with a
|
||||||
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
||||||
//
|
//
|
||||||
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
|
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
|
||||||
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
||||||
// words in normalize(text), return ONLY those matches as auto. This prevents
|
// words in normalize(text), return ONLY those matches as auto. This prevents
|
||||||
// nickname "tov" from matching inside surname "ottova".
|
// nickname "tov" from matching inside surname "ottova".
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py and
|
// Expected values verified against legacy/scripts/match_payments.py and
|
||||||
// tests/test_match_members.py on 2026-05-06:
|
// tests/test_match_members.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import match_members
|
// from match_payments import match_members
|
||||||
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var (
|
|||||||
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
||||||
// dropped.
|
// dropped.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py _build_name_variants.
|
// Ports legacy/scripts/match_payments.py _build_name_variants.
|
||||||
func BuildNameVariants(name string) []string {
|
func BuildNameVariants(name string) []string {
|
||||||
var nickname string
|
var nickname string
|
||||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
package matching
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c '
|
// PYTHONPATH=legacy/scripts:legacy python3 -c '
|
||||||
// from match_payments import _build_name_variants
|
// from match_payments import _build_name_variants
|
||||||
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
||||||
// print(repr(n), "->", _build_name_variants(n))
|
// print(repr(n), "->", _build_name_variants(n))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
|
// Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
|
||||||
package money
|
package money
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||||
|
|
||||||
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
||||||
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
|
// as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
|
||||||
//
|
//
|
||||||
// - empty input → (0, nil)
|
// - empty input → (0, nil)
|
||||||
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from infer_payments import parse_czk_amount
|
// from infer_payments import parse_czk_amount
|
||||||
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||||
// "1 500", "1500.00", "1 500.00",
|
// "1 500", "1500.00", "1 500.00",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
|
// Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
|
||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
|||||||
|
|
||||||
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
||||||
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
||||||
// Ports scripts/match_payments.py canonical_member_key.
|
// Ports legacy/scripts/match_payments.py canonical_member_key.
|
||||||
func canonicalMemberKey(name string) string {
|
func canonicalMemberKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ type monthExpected struct {
|
|||||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py reconcile.
|
// Ports legacy/scripts/match_payments.py reconcile.
|
||||||
func Reconcile(
|
func Reconcile(
|
||||||
members []Member,
|
members []Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package reconcile
|
package reconcile
|
||||||
|
|
||||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
||||||
//
|
//
|
||||||
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package synch ports the bank-sync deduplication helper from
|
// Package synch ports the bank-sync deduplication helper from
|
||||||
// scripts/sync_fio_to_sheets.py.
|
// legacy/scripts/sync_fio_to_sheets.py.
|
||||||
package synch
|
package synch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,7 +30,7 @@ type Transaction struct {
|
|||||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||||
// as the dedup key in column K of the payments sheet.
|
// as the dedup key in column K of the payments sheet.
|
||||||
//
|
//
|
||||||
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
|
// Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||||
func GenerateSyncID(tx Transaction) string {
|
func GenerateSyncID(tx Transaction) string {
|
||||||
currency := tx.Currency
|
currency := tx.Currency
|
||||||
if currency == "" {
|
if currency == "" {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
// All expected digests verified against the live Python implementation on 2026-05-06:
|
// All expected digests verified against the live Python implementation on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from sync_fio_to_sheets import generate_sync_id
|
// from sync_fio_to_sheets import generate_sync_id
|
||||||
// cases = [
|
// cases = [
|
||||||
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||||
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
|
|||||||
|
|
||||||
// All expected strings verified against the live Python implementation on 2026-05-06:
|
// All expected strings verified against the live Python implementation on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
||||||
// print(repr(v), "->", repr(str(v)))
|
// print(repr(v), "->", repr(str(v)))
|
||||||
// '
|
// '
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
||||||
// mirroring scripts/cache_utils.py.
|
// mirroring legacy/scripts/cache_utils.py.
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,7 +26,7 @@ type cacheFile struct {
|
|||||||
|
|
||||||
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
||||||
//
|
//
|
||||||
// Two TTL knobs mirror scripts/cache_utils.py:
|
// Two TTL knobs mirror legacy/scripts/cache_utils.py:
|
||||||
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
|
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
|
||||||
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type httpDoer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiClient fetches transactions from the Fio REST API (JSON).
|
// apiClient fetches transactions from the Fio REST API (JSON).
|
||||||
// Ports scripts/fio_utils.py fetch_transactions_api.
|
// Ports legacy/scripts/fio_utils.py fetch_transactions_api.
|
||||||
type apiClient struct {
|
type apiClient struct {
|
||||||
token string
|
token string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
||||||
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
// Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
|
||||||
type transparentClient struct {
|
type transparentClient struct {
|
||||||
accountNum string
|
accountNum string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
|
|||||||
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
||||||
// sheet using name and month matching against the member list.
|
// sheet using name and month matching against the member list.
|
||||||
// Returns the number of rows updated (or that would be updated on dry-run).
|
// Returns the number of rows updated (or that would be updated on dry-run).
|
||||||
// Ports scripts/infer_payments.py infer_payments.
|
// Ports legacy/scripts/infer_payments.py infer_payments.
|
||||||
func InferPayments(
|
func InferPayments(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// columnLabels is the canonical header for the payments sheet.
|
// columnLabels is the canonical header for the payments sheet.
|
||||||
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py.
|
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
|
||||||
var columnLabels = []string{
|
var columnLabels = []string{
|
||||||
"Date", "Amount", "manual fix", "Person", "Purpose",
|
"Date", "Amount", "manual fix", "Person", "Purpose",
|
||||||
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
||||||
@@ -37,7 +37,7 @@ type SyncOpts struct {
|
|||||||
|
|
||||||
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
||||||
// new ones to the payments sheet. Returns the number of rows appended.
|
// new ones to the payments sheet. Returns the number of rows appended.
|
||||||
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
|
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
|
||||||
func SyncToSheets(
|
func SyncToSheets(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printFeesTable writes a fixed-width adult-fees table to w.
|
// printFeesTable writes a fixed-width adult-fees table to w.
|
||||||
// Mirrors scripts/calculate_fees.py main().
|
// Mirrors legacy/scripts/calculate_fees.py main().
|
||||||
//
|
//
|
||||||
// Verify with:
|
// Verify with:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||||
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
||||||
type row struct {
|
type row struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
package membership
|
||||||
|
|
||||||
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
|
// Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
|
||||||
//
|
//
|
||||||
// (feed equivalent fixture data via attendance sheet or local CSV)
|
// (feed equivalent fixture data via attendance sheet or local CSV)
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printReconcileReport writes the full balance report to w.
|
// printReconcileReport writes the full balance report to w.
|
||||||
// Mirrors scripts/match_payments.py print_report().
|
// Mirrors legacy/scripts/match_payments.py print_report().
|
||||||
//
|
//
|
||||||
// Verify with:
|
// Verify with:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
// ...'
|
// ...'
|
||||||
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
package membership
|
||||||
|
|
||||||
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
|
// Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from match_payments import print_report
|
// from match_payments import print_report
|
||||||
// result = {
|
// result = {
|
||||||
// "members": {
|
// "members": {
|
||||||
|
|||||||
@@ -18,20 +18,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
|
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
|
||||||
const (
|
const (
|
||||||
colName = 0
|
colName = 0
|
||||||
colTier = 1
|
colTier = 1
|
||||||
firstDateCol = 3
|
firstDateCol = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||||
// Source month → target month (source attendance accumulated into target).
|
// Source month → target month (source attendance accumulated into target).
|
||||||
var AdultMergedMonths = map[string]string{
|
var AdultMergedMonths = map[string]string{
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
}
|
}
|
||||||
|
|
||||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
|
||||||
var JuniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
"2025-12": "2026-01",
|
"2025-12": "2026-01",
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
@@ -127,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Attendance CSV parsing (ports scripts/attendance.py)
|
// Attendance CSV parsing (ports legacy/scripts/attendance.py)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
|
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
|
||||||
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
|
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
|
||||||
func parseDates(header []string) []struct {
|
func parseDates(header []string) []struct {
|
||||||
col int
|
col int
|
||||||
month string
|
month string
|
||||||
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
|
|||||||
|
|
||||||
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
||||||
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
||||||
// Ports scripts/attendance.py get_members_with_fees.
|
// Ports legacy/scripts/attendance.py get_members_with_fees.
|
||||||
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(rows) < 2 {
|
if len(rows) < 2 {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
@@ -246,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
|||||||
|
|
||||||
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
||||||
// with the junior sheet, then calling CalculateJuniorFee.
|
// with the junior sheet, then calling CalculateJuniorFee.
|
||||||
// Ports scripts/attendance.py get_junior_members_with_fees.
|
// Ports legacy/scripts/attendance.py get_junior_members_with_fees.
|
||||||
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
@@ -362,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
|
// Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||||
@@ -466,7 +466,7 @@ func parseFloat(v any) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
|
// Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
|
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type monthSums struct{ expected, paid int }
|
type monthSums struct{ expected, paid int }
|
||||||
|
|
||||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||||
// Mirrors scripts/views.py:build_adults_view_model.
|
// Mirrors legacy/scripts/views.py:build_adults_view_model.
|
||||||
func buildAdultsResponse(
|
func buildAdultsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||||
// Mirrors scripts/views.py:get_month_labels.
|
// Mirrors legacy/scripts/views.py:get_month_labels.
|
||||||
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||||
labels := make(map[string]string, len(sortedMonths))
|
labels := make(map[string]string, len(sortedMonths))
|
||||||
for _, m := range sortedMonths {
|
for _, m := range sortedMonths {
|
||||||
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
|||||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||||
// Mirrors scripts/match_payments.py:canonical_member_key.
|
// Mirrors legacy/scripts/match_payments.py:canonical_member_key.
|
||||||
func canonicalKey(name string) string {
|
func canonicalKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||||
// canonicalizing names against memberNames where possible.
|
// canonicalizing names against memberNames where possible.
|
||||||
// Mirrors scripts/views.py:group_payments_by_person (without the
|
// Mirrors legacy/scripts/views.py:group_payments_by_person (without the
|
||||||
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||||
canonicalByKey := make(map[string]string, len(memberNames))
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||||
// Mirrors scripts/views.py:build_juniors_view_model.
|
// Mirrors legacy/scripts/views.py:build_juniors_view_model.
|
||||||
func buildJuniorsResponse(
|
func buildJuniorsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||||
// Mirrors scripts/views.py:build_payments_view_model.
|
// Mirrors legacy/scripts/views.py:build_payments_view_model.
|
||||||
func buildPaymentsResponse(
|
func buildPaymentsResponse(
|
||||||
txns []domreconcile.Transaction,
|
txns []domreconcile.Transaction,
|
||||||
memberNames []string,
|
memberNames []string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// VersionResponse is the JSON contract for GET /api/version.
|
// VersionResponse is the JSON contract for GET /api/version.
|
||||||
// Keys match Python's BUILD_META dict (see app.py).
|
// Keys match Python's BUILD_META dict (see legacy/app.py).
|
||||||
type VersionResponse struct {
|
type VersionResponse struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
|||||||
Vendored
+3
-3
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
|
|||||||
To refresh a single function:
|
To refresh a single function:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \
|
PYTHONPATH=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \
|
||||||
| while IFS= read -r line; do
|
| while IFS= read -r line; do
|
||||||
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
||||||
echo "$line" | python3 scripts/scrub_fixtures.py \
|
echo "$line" | python3 legacy/scripts/scrub_fixtures.py \
|
||||||
> go/tests/fixtures/pure/normalize/${id}.json
|
> go/tests/fixtures/pure/normalize/${id}.json
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
|
|||||||
|
|
||||||
## Adding a new fixture
|
## Adding a new fixture
|
||||||
|
|
||||||
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
|
1. Add a seed to `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
|
||||||
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
||||||
is new.
|
is new.
|
||||||
3. Run the single-file capture recipe above and review the diff.
|
3. Run the single-file capture recipe above and review the diff.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
|
||||||
|
|
||||||
func TestBuildNameVariantsParity(t *testing.T) {
|
func TestBuildNameVariantsParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
|
||||||
|
|
||||||
func TestCalculateFeeParity(t *testing.T) {
|
func TestCalculateFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
|
||||||
|
|
||||||
func TestCalculateJuniorFeeParity(t *testing.T) {
|
func TestCalculateJuniorFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))"
|
||||||
|
|
||||||
func TestFormatDateParity(t *testing.T) {
|
func TestFormatDateParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from sync_fio_to_sheets import generate_sync_id
|
// from sync_fio_to_sheets import generate_sync_id
|
||||||
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import infer_transaction_details
|
// from match_payments import infer_transaction_details
|
||||||
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
|
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
|
||||||
|
|
||||||
func TestMatchMembersParity(t *testing.T) {
|
func TestMatchMembersParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
|
||||||
|
|
||||||
func TestNormalizeParity(t *testing.T) {
|
func TestNormalizeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
|
||||||
//
|
//
|
||||||
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
|
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
|
||||||
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
|
||||||
|
|
||||||
func TestParseMonthReferencesParity(t *testing.T) {
|
func TestParseMonthReferencesParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import reconcile
|
// from match_payments import reconcile
|
||||||
// # ... build members/months/txs/exceptions...
|
// # ... build members/months/txs/exceptions...
|
||||||
// import json; print(json.dumps(reconcile(...)))"
|
// import json; print(json.dumps(reconcile(...)))"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
**/__pycache__
|
||||||
*.pyc
|
**/*.pyc
|
||||||
.claude
|
.claude
|
||||||
.gemini
|
.gemini
|
||||||
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
|
|||||||
pillow \
|
pillow \
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.10"
|
version = "0.10"
|
||||||
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
|
description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"google-api-python-client>=2.162.0",
|
"google-api-python-client>=2.162.0",
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regenerate the full fixture corpus.
|
# Regenerate the full fixture corpus.
|
||||||
# Safe to re-run — always overwrites.
|
# Safe to re-run — always overwrites.
|
||||||
# Requires: tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
|
# Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REPO="$(cd "$LEGACY/.." && pwd)"
|
||||||
FIXTURES="$REPO/go/tests/fixtures"
|
FIXTURES="$REPO/go/tests/fixtures"
|
||||||
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py"
|
CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
|
||||||
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py"
|
SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
|
||||||
|
|
||||||
run_func() {
|
run_func() {
|
||||||
local func="$1"
|
local func="$1"
|
||||||
@@ -18,7 +19,7 @@ run_func() {
|
|||||||
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
||||||
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
|
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
|
||||||
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||||
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
|
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
|
|||||||
mkdir -p "$FIXTURES/reconcile"
|
mkdir -p "$FIXTURES/reconcile"
|
||||||
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
||||||
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
|
||||||
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
|
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
||||||
Generated
Reference in New Issue
Block a user