Compare commits
11 Commits
feat/go-m5
...
4d035213b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d035213b5 | |||
| 723152cdad | |||
| e5a272b682 | |||
| 8b3064ffab | |||
| 423c3e2a4b | |||
| f4c497681f | |||
| 40e4a9e45e | |||
| 68810369bd | |||
| 2b7eff14c4 | |||
| 7d48e8f607 | |||
| be4ecef20f |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
|
||||
|
||||
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
|
||||
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
|
||||
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
|
||||
|
||||
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
||||
|
||||
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
||||
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
|
||||
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
|
||||
|
||||
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
|
||||
|
||||
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract.
|
||||
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
|
||||
|
||||
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
|
||||
|
||||
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
|
||||
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
|
||||
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
|
||||
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
|
||||
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
|
||||
- PR #17.
|
||||
|
||||
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
|
||||
|
||||
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
||||
|
||||
81
app.py
81
app.py
@@ -7,7 +7,7 @@ import os
|
||||
import io
|
||||
import qrcode
|
||||
import logging
|
||||
from flask import Flask, render_template, g, send_file, request
|
||||
from flask import Flask, render_template, g, send_file, request, jsonify
|
||||
|
||||
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
@@ -68,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
|
||||
"tag": "dev", "commit": "local", "build_date": ""
|
||||
}
|
||||
|
||||
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
|
||||
|
||||
warmup_cache()
|
||||
|
||||
@app.before_request
|
||||
@@ -144,6 +154,75 @@ def sync_bank():
|
||||
def version():
|
||||
return BUILD_META
|
||||
|
||||
@app.route("/api/version")
|
||||
def api_version():
|
||||
return jsonify(BUILD_META)
|
||||
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/juniors")
|
||||
def api_juniors():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
if not junior_members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
junior_members, sorted_months = junior_members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
adapted_members = adapt_junior_members(junior_members)
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
vm = build_juniors_view_model(
|
||||
junior_members, adapted_members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
|
||||
@app.route("/api/payments")
|
||||
def api_payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
member_names = []
|
||||
if adults_data:
|
||||
member_names.extend(name for name, _, _ in adults_data[0])
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
vm = build_payments_view_model(
|
||||
transactions, member_names,
|
||||
attendance_url=attendance_url, payments_url=payments_url,
|
||||
)
|
||||
return jsonify(vm)
|
||||
|
||||
@app.route("/adults")
|
||||
def adults_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
|
||||
@@ -98,8 +98,8 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
|
||||
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||
|
||||
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
||||
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
|
||||
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||
|
||||
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||
|
||||
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# M5.3 — Python `/api/X` shadow endpoints
|
||||
|
||||
Companion to:
|
||||
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
|
||||
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
|
||||
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
|
||||
|
||||
## Context
|
||||
|
||||
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
|
||||
|
||||
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||
|
||||
## Approach
|
||||
|
||||
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
|
||||
|
||||
### Decisions
|
||||
|
||||
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
|
||||
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
|
||||
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
|
||||
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
|
||||
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
|
||||
|
||||
### The shim
|
||||
|
||||
```python
|
||||
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||
out = dict(vm)
|
||||
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||
return out
|
||||
```
|
||||
|
||||
Note the **rename**: `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
|
||||
|
||||
### Route skeleton (`/api/adults`)
|
||||
|
||||
```python
|
||||
@app.route("/api/adults")
|
||||
def api_adults():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
if not members_data:
|
||||
return jsonify({"error": "no data"}), 503
|
||||
members, sorted_months = members_data
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||
)
|
||||
return jsonify(_unwrap_view_model_for_api(vm))
|
||||
```
|
||||
|
||||
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
- [app.py](app.py)
|
||||
- L10: add `jsonify` to `from flask import ...`.
|
||||
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
|
||||
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
|
||||
- [tests/test_app.py](tests/test_app.py)
|
||||
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
|
||||
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
|
||||
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
|
||||
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
|
||||
|
||||
## Reusable existing code
|
||||
|
||||
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
|
||||
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
|
||||
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
|
||||
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
|
||||
2. `python -c "import app"` — catches the missing `jsonify` import.
|
||||
3. `make web-py`, then:
|
||||
- `curl -s localhost:5001/api/version | jq .` → `{tag, commit, build_date}`.
|
||||
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
|
||||
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
|
||||
- Same checks on `/api/juniors` and `/api/payments`.
|
||||
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
|
||||
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
|
||||
|
||||
## Out of scope (M5.4 will surface and resolve)
|
||||
|
||||
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
|
||||
|
||||
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
|
||||
- **`unmatched[]`**: same divergence (same raw row dicts).
|
||||
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
|
||||
- **`Decimal` / float precision** in `grouped_payments` amounts.
|
||||
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
|
||||
|
||||
## Branch + MR
|
||||
|
||||
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.
|
||||
@@ -73,10 +73,18 @@ func serverCmd(args []string) {
|
||||
cfg.ServerAddr = *addr
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logger := logging.New(cfg.LogLevel)
|
||||
|
||||
sources, err := membership.NewSources(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||
|
||||
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func Load() Config {
|
||||
return Config{
|
||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||
CacheDir: env("CACHE_DIR", "tmp"),
|
||||
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||
|
||||
@@ -20,10 +20,13 @@ type Exception struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// FeeData holds the expected fee and attendance count for one member in one month.
|
||||
// FeeData holds the expected fee and attendance data for one member in one month.
|
||||
type FeeData struct {
|
||||
Expected int
|
||||
Attendance int
|
||||
Expected int
|
||||
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
|
||||
Attendance int
|
||||
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
|
||||
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
|
||||
}
|
||||
|
||||
// Member is one row from the attendance sheet.
|
||||
@@ -39,11 +42,15 @@ type Member struct {
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Amount float64
|
||||
ManualFix string // "manual fix" column; non-empty disables re-inference
|
||||
Person string // comma-separated canonical names (empty → use inference)
|
||||
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||
InferredAmount *float64 // nil → fall back to Amount
|
||||
Sender string
|
||||
VS string // Variabilní symbol (Czech variable payment symbol)
|
||||
Message string
|
||||
BankID string
|
||||
SyncID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
@@ -69,8 +76,11 @@ type OtherEntry struct {
|
||||
// MonthData is the ledger state for one member in one month.
|
||||
type MonthData struct {
|
||||
Expected int
|
||||
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
||||
OriginalExpected int
|
||||
AttendanceCount int
|
||||
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
|
||||
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
|
||||
Exception *Exception
|
||||
Paid float64
|
||||
Transactions []TxEntry
|
||||
@@ -173,8 +183,11 @@ func Reconcile(
|
||||
|
||||
ledger[name][m] = MonthData{
|
||||
Expected: expected,
|
||||
IsUnknown: fd.IsUnknown,
|
||||
OriginalExpected: originalExpected,
|
||||
AttendanceCount: attendanceCount,
|
||||
JuniorAttendance: fd.JuniorAttendance,
|
||||
AdultAttendance: fd.AdultAttendance,
|
||||
Exception: exInfo,
|
||||
Paid: 0,
|
||||
Transactions: []TxEntry{},
|
||||
|
||||
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
|
||||
|
||||
func TestReconcileExceptionOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
exceptions := map[ExceptionKey]Exception{
|
||||
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
|
||||
|
||||
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{
|
||||
"2026-02": {750, 3},
|
||||
"2026-03": {350, 3},
|
||||
"2026-04": {150, 2},
|
||||
"2026-02": {Expected: 750, Attendance: 3},
|
||||
"2026-03": {Expected: 350, Attendance: 3},
|
||||
"2026-04": {Expected: 150, Attendance: 2},
|
||||
},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
amount := 1250.0
|
||||
@@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
|
||||
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
||||
|
||||
@@ -158,8 +158,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
|
||||
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txFn := func(person string) Transaction {
|
||||
return Transaction{
|
||||
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
||||
@@ -232,7 +232,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
|
||||
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
Person: "Někdo Neznámý", Purpose: "2026-04",
|
||||
@@ -252,7 +252,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 750,
|
||||
Person: "[?] Alice", Purpose: "2026-01",
|
||||
@@ -269,7 +269,7 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||
func TestReconcileOtherPurpose(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 300,
|
||||
Person: "Alice", Purpose: "other:shirt",
|
||||
@@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
|
||||
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 1200,
|
||||
Person: "Alice", Purpose: "2026-01, 2026-02",
|
||||
@@ -322,7 +322,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||
func TestReconcileInferenceFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
// Person and Purpose are empty → inference path
|
||||
@@ -340,7 +340,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
|
||||
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 500,
|
||||
// empty person+purpose and sender name not matching any member
|
||||
@@ -360,7 +360,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) {
|
||||
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
||||
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
|
||||
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
|
||||
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||
{"07/05/26", "2026-05-07"}, // slash variant
|
||||
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||
{"07/05/26", "2026-05-07"}, // slash variant
|
||||
{"", ""},
|
||||
{"invalid", ""},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package banksync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"fuj-management/go/internal/io/fio"
|
||||
)
|
||||
|
||||
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
||||
|
||||
@@ -25,12 +25,12 @@ const (
|
||||
firstDateCol = 3
|
||||
)
|
||||
|
||||
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||
// Source month → target month (source attendance accumulated into target).
|
||||
var adultMergedMonths = map[string]string{}
|
||||
var AdultMergedMonths = map[string]string{}
|
||||
|
||||
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||
var juniorMergedMonths = map[string]string{
|
||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||
var JuniorMergedMonths = map[string]string{
|
||||
"2025-12": "2026-01",
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
dates := parseDates(rows[0])
|
||||
months := groupByMonth(dates, adultMergedMonths)
|
||||
months := groupByMonth(dates, AdultMergedMonths)
|
||||
sortedMonths := sortedKeys(months)
|
||||
|
||||
var members []reconcile.Member
|
||||
@@ -243,8 +243,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
|
||||
mainDates := parseDates(adultRows[0])
|
||||
juniorDates := parseDates(juniorRows[0])
|
||||
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
|
||||
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
|
||||
|
||||
allMonths := make(map[string]bool)
|
||||
for m := range mainMonths {
|
||||
@@ -337,7 +337,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
if !exp.Unknown {
|
||||
fee = exp.Value
|
||||
}
|
||||
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
|
||||
feeMap[m] = reconcile.FeeData{
|
||||
Expected: fee,
|
||||
IsUnknown: exp.Unknown,
|
||||
Attendance: total,
|
||||
JuniorAttendance: c.junior,
|
||||
AdultAttendance: c.adult,
|
||||
}
|
||||
}
|
||||
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
||||
}
|
||||
@@ -365,11 +371,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
}
|
||||
idxDate := idx("date")
|
||||
idxAmount := idx("amount")
|
||||
idxManualFix := idx("manual fix")
|
||||
idxPerson := idx("person")
|
||||
idxPurpose := idx("purpose")
|
||||
idxInferred := idx("inferred amount")
|
||||
idxSender := idx("sender")
|
||||
idxVS := idx("vs")
|
||||
idxMessage := idx("message")
|
||||
idxBankID := idx("bank id")
|
||||
idxSyncID := idx("sync id")
|
||||
|
||||
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
||||
if idx(label) == -1 {
|
||||
@@ -384,9 +394,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
return fmt.Sprint(row[i])
|
||||
}
|
||||
|
||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||
// dispatch on the underlying numeric type (Sheets returns serial-day
|
||||
// numbers as float64). Stringifying first defeats that dispatch.
|
||||
getRaw := func(row []any, i int) any {
|
||||
if i < 0 || i >= len(row) {
|
||||
return nil
|
||||
}
|
||||
return row[i]
|
||||
}
|
||||
|
||||
var txns []reconcile.Transaction
|
||||
for _, row := range rows[1:] {
|
||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
||||
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||
amountRaw := row[idxAmount]
|
||||
if idxAmount < 0 || idxAmount >= len(row) {
|
||||
amountRaw = ""
|
||||
@@ -403,11 +423,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
txns = append(txns, reconcile.Transaction{
|
||||
Date: dateStr,
|
||||
Amount: amount,
|
||||
ManualFix: getVal(row, idxManualFix),
|
||||
Person: getVal(row, idxPerson),
|
||||
Purpose: getVal(row, idxPurpose),
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: getVal(row, idxSender),
|
||||
VS: getVal(row, idxVS),
|
||||
Message: getVal(row, idxMessage),
|
||||
BankID: getVal(row, idxBankID),
|
||||
SyncID: getVal(row, idxSyncID),
|
||||
})
|
||||
}
|
||||
return txns, nil
|
||||
|
||||
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
|
||||
|
||||
func TestLoadTransactions(t *testing.T) {
|
||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
||||
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
||||
// serial-day form (float64) — the API returns either depending on cell
|
||||
// formatting, and FormatDate must handle both.
|
||||
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||
sh := &sheets.Fake{Values: map[string][][]any{
|
||||
paymentsKey: {
|
||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
||||
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
||||
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
|
||||
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
||||
},
|
||||
}}
|
||||
s := buildSources(t, &attendance.Fake{}, sh)
|
||||
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if txns[0].Amount != 700 {
|
||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
||||
}
|
||||
if txns[0].Date != "2026-04-01" {
|
||||
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
||||
}
|
||||
if txns[1].Date != "2026-05-05" {
|
||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExceptions(t *testing.T) {
|
||||
|
||||
263
go/internal/web/api/build_adults.go
Normal file
263
go/internal/web/api/build_adults.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
type monthSums struct{ expected, paid int }
|
||||
|
||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||
// Mirrors scripts/views.py:build_adults_view_model.
|
||||
func buildAdultsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) AdultsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
|
||||
|
||||
// Collect tier-A names, sorted.
|
||||
var adultNames []string
|
||||
allNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
if m.Tier == "A" {
|
||||
adultNames = append(adultNames, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(adultNames)
|
||||
|
||||
// Per-month aggregate totals (expected and paid integers).
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range adultNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// Credits and debts (settled balance, past months only).
|
||||
var credits, debts []Credit
|
||||
for _, name := range adultNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all members (not just adults).
|
||||
memberData := make(map[string]AdultsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]AdultsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = AdultsMonthData{
|
||||
Expected: md.Expected,
|
||||
OriginalExpected: md.OriginalExpected,
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = AdultsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
return AdultsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.BankAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAdultMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
expected := md.Expected
|
||||
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
t.expected += expected
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
|
||||
isOverridden = true
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
|
||||
}
|
||||
} else {
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK", expected)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case expected > 0:
|
||||
amountToPay = max(0, expected-paid)
|
||||
switch {
|
||||
case paid >= expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if expected > 0 || paid > 0 {
|
||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
|
||||
}
|
||||
|
||||
row.Months = append(row.Months, MonthCell{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
|
||||
func rawMonthLabel(m string) string {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
return dt.Format("01/2006")
|
||||
}
|
||||
|
||||
func joinComma(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += ", " + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinPlus(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += "+" + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
184
go/internal/web/api/build_common.go
Normal file
184
go/internal/web/api/build_common.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||
// Mirrors scripts/views.py:get_month_labels.
|
||||
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||
labels := make(map[string]string, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
labels[m] = m
|
||||
continue
|
||||
}
|
||||
var mergedIn []string
|
||||
for src, dst := range mergedMonths {
|
||||
if dst == m {
|
||||
mergedIn = append(mergedIn, src)
|
||||
}
|
||||
}
|
||||
sort.Strings(mergedIn)
|
||||
if len(mergedIn) == 0 {
|
||||
labels[m] = dt.Format("Jan 2006")
|
||||
continue
|
||||
}
|
||||
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
|
||||
sort.Strings(allMonths)
|
||||
years := map[int]bool{}
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
years[d.Year()] = true
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(allMonths))
|
||||
if len(years) > 1 {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan 2006"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+")
|
||||
} else {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// labelsForMonths returns the display labels for sortedMonths in slice order.
|
||||
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
||||
out := make([]string, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
out[i] = labels[m]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||
|
||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||
// Mirrors scripts/match_payments.py:canonical_member_key.
|
||||
func canonicalKey(name string) string {
|
||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||
}
|
||||
|
||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||
// canonicalizing names against memberNames where possible.
|
||||
// Mirrors scripts/views.py:group_payments_by_person (without the
|
||||
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||
canonicalByKey := make(map[string]string, len(memberNames))
|
||||
for _, n := range memberNames {
|
||||
k := canonicalKey(n)
|
||||
if _, exists := canonicalByKey[k]; !exists {
|
||||
canonicalByKey[k] = n
|
||||
}
|
||||
}
|
||||
grouped := make(map[string][]RawTransaction)
|
||||
for _, tx := range txns {
|
||||
person := strings.TrimSpace(tx.Person)
|
||||
if person == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range strings.Split(person, ",") {
|
||||
p = questionMarkRe.ReplaceAllString(p, "")
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
key := p
|
||||
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
|
||||
key = canonical
|
||||
}
|
||||
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
for k := range grouped {
|
||||
sort.Slice(grouped[k], func(i, j int) bool {
|
||||
return grouped[k][i].Date > grouped[k][j].Date
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
|
||||
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
|
||||
inferredAmount := 0.0
|
||||
if tx.InferredAmount != nil {
|
||||
inferredAmount = *tx.InferredAmount
|
||||
}
|
||||
return RawTransaction{
|
||||
Date: tx.Date,
|
||||
Amount: tx.Amount,
|
||||
ManualFix: tx.ManualFix,
|
||||
Person: tx.Person,
|
||||
Purpose: tx.Purpose,
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: tx.Sender,
|
||||
VS: tx.VS,
|
||||
Message: tx.Message,
|
||||
BankID: tx.BankID,
|
||||
SyncID: tx.SyncID,
|
||||
}
|
||||
}
|
||||
|
||||
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
|
||||
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
|
||||
return MemberTxEntry{
|
||||
Amount: te.Amount,
|
||||
Date: te.Date,
|
||||
Sender: te.Sender,
|
||||
Message: te.Message,
|
||||
Confidence: te.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
|
||||
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
|
||||
return MemberOtherEntry{
|
||||
Amount: oe.Amount,
|
||||
Date: oe.Date,
|
||||
Sender: oe.Sender,
|
||||
Message: oe.Message,
|
||||
Purpose: oe.Purpose,
|
||||
Confidence: oe.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// settledBalance computes the settled balance: sum of (paid − expected) for months
|
||||
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
|
||||
// Python's isinstance(exp, int) guard (skips "?" months).
|
||||
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
|
||||
total := 0
|
||||
for m, md := range mr.Months {
|
||||
if m >= currentMonth || md.IsUnknown {
|
||||
continue
|
||||
}
|
||||
total += int(md.Paid) - md.Expected
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
|
||||
// json.Marshal emits [] instead of null.
|
||||
func ensureSlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
276
go/internal/web/api/build_juniors.go
Normal file
276
go/internal/web/api/build_juniors.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||
// Mirrors scripts/views.py:build_juniors_view_model.
|
||||
func buildJuniorsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) JuniorsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
||||
|
||||
allNames := make([]string, 0, len(members))
|
||||
juniorNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
juniorNames = append(juniorNames, m.Name)
|
||||
}
|
||||
sort.Strings(juniorNames)
|
||||
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range juniorNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
var credits, debts []Credit
|
||||
for _, name := range juniorNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all junior members.
|
||||
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = JuniorsMonthData{
|
||||
Expected: juniorExpected(md),
|
||||
OriginalExpected: juniorOriginalExpected(md),
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = JuniorsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
||||
"/edit#gid=" + config.JuniorSheetGID
|
||||
|
||||
return JuniorsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: juniorURL,
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.BankAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
}
|
||||
}
|
||||
|
||||
func buildJuniorMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
|
||||
// Update monthly totals (skip "?" months for expected).
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
if !md.IsUnknown {
|
||||
t.expected += md.Expected
|
||||
}
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
// Attendance breakdown string e.g. ":3J,2A".
|
||||
var breakdown string
|
||||
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
||||
switch {
|
||||
case jc > 0 && ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
||||
case jc > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ", jc)
|
||||
case ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dA", ac)
|
||||
}
|
||||
countStr := ""
|
||||
if md.AttendanceCount > 0 {
|
||||
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
||||
}
|
||||
|
||||
// Fee display string.
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil {
|
||||
overrideAmount := md.Exception.Amount
|
||||
var origStr string
|
||||
if md.IsUnknown {
|
||||
origStr = "?"
|
||||
isOverridden = true
|
||||
} else {
|
||||
origStr = strconv.Itoa(md.OriginalExpected)
|
||||
isOverridden = overrideAmount != md.OriginalExpected
|
||||
}
|
||||
if isOverridden {
|
||||
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
} else {
|
||||
if md.IsUnknown {
|
||||
feeDisplay = "? CZK" + countStr
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case md.IsUnknown:
|
||||
cellText = "?" + countStr
|
||||
case md.Expected > 0:
|
||||
switch {
|
||||
case paid >= md.Expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
amountToPay = md.Expected - paid
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
amountToPay = md.Expected
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
||||
}
|
||||
|
||||
row.Months = append(row.Months, MonthCell{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// juniorExpected converts domain MonthData to the Expected wire type.
|
||||
// When an exception exists it always produces a concrete int; otherwise
|
||||
// the "?" sentinel is used when IsUnknown=true.
|
||||
func juniorExpected(md domreconcile.MonthData) Expected {
|
||||
if md.Exception == nil && md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.Expected}
|
||||
}
|
||||
|
||||
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
||||
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
||||
if md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.OriginalExpected}
|
||||
}
|
||||
44
go/internal/web/api/build_payments.go
Normal file
44
go/internal/web/api/build_payments.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/config"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||
// Mirrors scripts/views.py:build_payments_view_model.
|
||||
func buildPaymentsResponse(
|
||||
txns []domreconcile.Transaction,
|
||||
memberNames []string,
|
||||
) PaymentsResponse {
|
||||
grouped := groupRawPaymentsByPerson(txns, memberNames)
|
||||
|
||||
// Add unmatched/unknown bucket for transactions with no person set.
|
||||
const unknownKey = "Unmatched / Unknown"
|
||||
for _, tx := range txns {
|
||||
if strings.TrimSpace(tx.Person) == "" {
|
||||
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
|
||||
if rows, ok := grouped[unknownKey]; ok {
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
|
||||
grouped[unknownKey] = rows
|
||||
}
|
||||
|
||||
sortedPeople := make([]string, 0, len(grouped))
|
||||
for p := range grouped {
|
||||
sortedPeople = append(sortedPeople, p)
|
||||
}
|
||||
sort.Strings(sortedPeople)
|
||||
|
||||
return PaymentsResponse{
|
||||
GroupedPayments: grouped,
|
||||
SortedPeople: sortedPeople,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
}
|
||||
}
|
||||
125
go/internal/web/api/handler.go
Normal file
125
go/internal/web/api/handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// Handler holds the shared dependencies for all /api/* routes.
|
||||
type Handler struct {
|
||||
BuildVersion string
|
||||
BuildCommit string
|
||||
BuildDate string
|
||||
Sources membership.Sources
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// ServeVersion handles GET /api/version.
|
||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, VersionResponse{
|
||||
Tag: h.BuildVersion,
|
||||
Commit: h.BuildCommit,
|
||||
BuildDate: h.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeAdults handles GET /api/adults.
|
||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||
}
|
||||
|
||||
// ServeJuniors handles GET /api/juniors.
|
||||
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||
}
|
||||
|
||||
// ServePayments handles GET /api/payments.
|
||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
txns, err := h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
|
||||
}
|
||||
|
||||
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
txns []domreconcile.Transaction,
|
||||
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
|
||||
err error,
|
||||
) {
|
||||
if adults {
|
||||
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
|
||||
} else {
|
||||
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load members: %w", err)
|
||||
return
|
||||
}
|
||||
txns, err = h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load transactions: %w", err)
|
||||
return
|
||||
}
|
||||
exceptions, err = h.Sources.LoadExceptions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load exceptions: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *Handler) allMemberNames(ctx context.Context) []string {
|
||||
var names []string
|
||||
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
|
||||
for _, m := range adults {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
|
||||
for _, m := range juniors {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if h.Logger != nil {
|
||||
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -15,9 +18,22 @@ type BuildInfo struct {
|
||||
}
|
||||
|
||||
// Run registers routes and starts the HTTP server on addr.
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
||||
h := &api.Handler{
|
||||
BuildVersion: build.Version,
|
||||
BuildCommit: build.Commit,
|
||||
BuildDate: build.BuildDate,
|
||||
Sources: sources,
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
||||
mux.HandleFunc("GET /api/version", h.ServeVersion)
|
||||
mux.HandleFunc("GET /api/adults", h.ServeAdults)
|
||||
mux.HandleFunc("GET /api/juniors", h.ServeJuniors)
|
||||
mux.HandleFunc("GET /api/payments", h.ServePayments)
|
||||
|
||||
logger.Info("starting server", "addr", addr)
|
||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import unittest
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from app import app
|
||||
|
||||
EXPECTED_ADULTS_KEYS = {
|
||||
"months", "raw_months", "results", "totals", "member_data", "month_labels",
|
||||
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
|
||||
"payments_url", "bank_account", "current_month",
|
||||
}
|
||||
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
|
||||
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
|
||||
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
|
||||
|
||||
|
||||
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||
@@ -97,5 +107,80 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertIn(b'500/500 CZK', response.data)
|
||||
self.assertIn(b'?', response.data)
|
||||
|
||||
def test_api_version(self):
|
||||
"""Test /api/version returns BUILD_META keys as JSON."""
|
||||
response = self.client.get('/api/version')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test payment',
|
||||
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||
}]
|
||||
response = self.client.get('/api/adults')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||
}]
|
||||
response = self.client.get('/api/juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
|
||||
self.assertIsInstance(data['member_data'], dict)
|
||||
self.assertIsInstance(data['month_labels'], dict)
|
||||
self.assertIsInstance(data['raw_payments'], dict)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_api_payments(self, mock_fetch_sheet, mock_cache):
|
||||
"""Test /api/payments returns JSON with correct top-level keys."""
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||
}]
|
||||
response = self.client.get('/api/payments')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.is_json)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
|
||||
self.assertIsInstance(data['grouped_payments'], dict)
|
||||
self.assertIsInstance(data['sorted_people'], list)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user