feat(go): IO layer behind interfaces (M4)
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s

- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs
- io/drive: Drive v3 modifiedTime client + Fake
- io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/
  WriteHeader/SortByDateColumn) + Fake with call-capture
- io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic
  writes; generic Get[T]; Python-compatible JSON format; Flush()
- io/fio: Client interface backed by Fio REST API (apiClient) and HTML
  scraper (transparentClient); Fake; testdata fixtures
- membership/sources: NewSources wires attendance CSV + Sheets + cache
  into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech
  month parsing + merged-month maps
- banksync: SyncToSheets (SHA-256 dedup, optional sort) and
  InferPayments ([?] review prefix, dry-run) — tested with fakes
- cmd/fuj: sync and infer subcommands wired; fees and reconcile use
  real NewSources; go.mod gains google.golang.org/api + x/net
- gofumpt extra-rules applied across all packages; lint clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:05:59 +02:00
parent 7afd12d9a5
commit 6465e2a221
45 changed files with 3292 additions and 46 deletions

View File

@@ -17,6 +17,10 @@ func (f fakeAttendanceLoader) LoadAdults(_ context.Context) ([]reconcile.Member,
return f.members, f.months, nil
}
func (f fakeAttendanceLoader) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, nil
}
func TestFeesReport(t *testing.T) {
t.Parallel()
loader := fakeAttendanceLoader{

View File

@@ -9,10 +9,10 @@ import (
// ErrIOPending is returned by stub loader methods until the M4 IO layer lands.
var ErrIOPending = errors.New("io layer not yet wired up; lands in milestone M4 (sheets/drive/fio)")
// AttendanceLoader loads processed adult attendance + computed fees from the
// attendance Google Sheet.
// AttendanceLoader loads attendance and computed fees from the attendance Google Sheet.
type AttendanceLoader interface {
LoadAdults(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error)
LoadJuniors(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error)
}
// TransactionLoader loads payment rows from the payments Google Sheet.
@@ -41,6 +41,10 @@ func (stubSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string,
return nil, nil, ErrIOPending
}
func (stubSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, ErrIOPending
}
func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
return nil, ErrIOPending
}

View File

@@ -19,6 +19,10 @@ func (f fakeSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string
return f.members, f.months, nil
}
func (f fakeSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, nil
}
func (f fakeSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
return f.txns, nil
}

View File

@@ -0,0 +1,477 @@
package membership
import (
"context"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/domain/czech"
"fuj-management/go/internal/domain/fees"
"fuj-management/go/internal/domain/matching"
"fuj-management/go/internal/domain/reconcile"
"fuj-management/go/internal/io/attendance"
"fuj-management/go/internal/io/cache"
"fuj-management/go/internal/io/drive"
"fuj-management/go/internal/io/sheets"
"sort"
"strconv"
"strings"
"time"
)
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
const (
colName = 0
colTier = 1
firstDateCol = 3
)
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
// Source month → target month (source attendance accumulated into target).
var adultMergedMonths = 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",
}
// attendanceFetcher abstracts CSV fetching so tests can inject a Fake.
type attendanceFetcher interface {
FetchAdults(ctx context.Context) ([][]string, error)
FetchJuniors(ctx context.Context) ([][]string, error)
}
// sheetReader abstracts Sheets API reads so tests can inject a Fake.
type sheetReader interface {
GetValues(ctx context.Context, spreadsheetID, a1Range string) ([][]any, error)
}
// realSources is the live implementation of Sources backed by Google APIs.
type realSources struct {
attendance attendanceFetcher
sheets sheetReader
cache *cache.FileCache
}
// NewSources builds a Sources backed by real Google Sheets and Drive APIs.
// Call this once at startup; the returned Sources is safe for concurrent use.
func NewSources(ctx context.Context, cfg config.Config) (Sources, error) {
driveCli, err := drive.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
if err != nil {
return nil, fmt.Errorf("drive client: %w", err)
}
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
if err != nil {
return nil, fmt.Errorf("sheets client: %w", err)
}
attendanceCli := attendance.New(nil, config.AttendanceSheetID, config.AttendanceAdultSheetGID, config.JuniorSheetGID)
fc := cache.New(driveCli, cfg.CacheDir, config.CacheSheetMap, cfg.CacheTTL, cfg.CacheAPICheckTTL)
return &realSources{
attendance: attendanceCli,
sheets: sheetsCli,
cache: fc,
}, nil
}
// LoadAdults fetches adult attendance (cached) and returns reconcile.Members for all tiers.
func (s *realSources) LoadAdults(ctx context.Context) ([]reconcile.Member, []string, error) {
rows, err := cache.Get(ctx, s.cache, "attendance_regular", s.attendance.FetchAdults)
if err != nil {
return nil, nil, fmt.Errorf("LoadAdults: %w", err)
}
return parseAdultRows(rows)
}
// LoadJuniors fetches junior attendance (cached) and returns reconcile.Members for juniors.
func (s *realSources) LoadJuniors(ctx context.Context) ([]reconcile.Member, []string, error) {
// Junior data needs both the adult tab (tier="J" rows) and the junior tab.
adultRows, err := cache.Get(ctx, s.cache, "attendance_regular", s.attendance.FetchAdults)
if err != nil {
return nil, nil, fmt.Errorf("LoadJuniors (adult tab): %w", err)
}
juniorRows, err := cache.Get(ctx, s.cache, "attendance_juniors", s.attendance.FetchJuniors)
if err != nil {
return nil, nil, fmt.Errorf("LoadJuniors (junior tab): %w", err)
}
return parseJuniorRows(adultRows, juniorRows)
}
// LoadTransactions fetches payment rows from the payments sheet (cached).
func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transaction, error) {
rows, err := cache.Get(ctx, s.cache, "payments_transactions",
func(ctx context.Context) ([][]any, error) {
return s.sheets.GetValues(ctx, config.PaymentsSheetID, "A1:Z")
})
if err != nil {
return nil, fmt.Errorf("LoadTransactions: %w", err)
}
return parseTransactionRows(rows)
}
// LoadExceptions fetches the exceptions tab (cached).
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
rows, err := cache.Get(ctx, s.cache, "exceptions_dict",
func(ctx context.Context) ([][]any, error) {
return s.sheets.GetValues(ctx, config.PaymentsSheetID, "'exceptions'!A2:D")
})
if err != nil {
return nil, fmt.Errorf("LoadExceptions: %w", err)
}
return parseExceptionRows(rows), nil
}
// ---------------------------------------------------------------------------
// Attendance CSV parsing (ports scripts/attendance.py)
// ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
func parseDates(header []string) []struct {
col int
month string
} {
var out []struct {
col int
month string
}
for i := firstDateCol; i < len(header); i++ {
raw := strings.TrimSpace(header[i])
if raw == "" {
continue
}
var dt time.Time
var err error
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
dt, err = time.Parse(fmt_, raw)
if err == nil {
break
}
}
if err != nil {
continue
}
out = append(out, struct {
col int
month string
}{col: i, month: dt.Format("2006-01")})
}
return out
}
// groupByMonth groups column indices by YYYY-MM, applying merged month mapping.
func groupByMonth(dates []struct {
col int
month string
}, mergedMonths map[string]string,
) map[string][]int {
out := make(map[string][]int)
for _, d := range dates {
target := d.month
if v, ok := mergedMonths[d.month]; ok {
target = v
}
out[target] = append(out[target], d.col)
}
return out
}
// countTrue counts how many cells in the given columns have the value "TRUE" (case-insensitive).
func countTrue(row []string, cols []int) int {
n := 0
for _, c := range cols {
if c < len(row) && strings.EqualFold(strings.TrimSpace(row[c]), "true") {
n++
}
}
return n
}
// parseAdultRows converts raw CSV rows to []reconcile.Member.
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
// Ports scripts/attendance.py get_members_with_fees.
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
if len(rows) < 2 {
return nil, nil, nil
}
dates := parseDates(rows[0])
months := groupByMonth(dates, adultMergedMonths)
sortedMonths := sortedKeys(months)
var members []reconcile.Member
for _, row := range rows[1:] {
if len(row) == 0 {
continue
}
first := strings.TrimSpace(row[colName])
if strings.Contains(strings.ToLower(first), "# last line") {
break
}
if strings.HasPrefix(first, "#") || first == "" {
continue
}
if strings.ToLower(first) == "jméno" || strings.ToLower(first) == "name" || strings.ToLower(first) == "jmeno" {
continue
}
tier := ""
if len(row) > colTier {
tier = strings.ToUpper(strings.TrimSpace(row[colTier]))
}
feeMap := make(map[string]reconcile.FeeData, len(sortedMonths))
for _, m := range sortedMonths {
cols := months[m]
count := countTrue(row, cols)
var fee int
if tier == "A" {
fee = fees.CalculateFee(count, m)
}
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: count}
}
members = append(members, reconcile.Member{Name: first, Tier: tier, Fees: feeMap})
}
return members, sortedMonths, nil
}
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
// with the junior sheet, then calling CalculateJuniorFee.
// Ports scripts/attendance.py get_junior_members_with_fees.
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
if len(adultRows) < 2 || len(juniorRows) < 2 {
return nil, nil, nil
}
mainDates := parseDates(adultRows[0])
juniorDates := parseDates(juniorRows[0])
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
allMonths := make(map[string]bool)
for m := range mainMonths {
allMonths[m] = true
}
for m := range jrMonths {
allMonths[m] = true
}
sortedMonths := sortedKeys(allMonths)
type counts struct{ adult, junior int }
merged := make(map[string]*struct {
tier string
months map[string]counts
})
// Tier-J rows from adult tab
for _, row := range adultRows[1:] {
if len(row) == 0 {
continue
}
first := strings.TrimSpace(row[colName])
if strings.Contains(strings.ToLower(first), "# last line") {
break
}
if strings.HasPrefix(first, "#") || first == "" {
continue
}
tier := ""
if len(row) > colTier {
tier = strings.ToUpper(strings.TrimSpace(row[colTier]))
}
if tier != "J" {
continue
}
if _, ok := merged[first]; !ok {
merged[first] = &struct {
tier string
months map[string]counts
}{tier: tier, months: make(map[string]counts)}
}
for _, m := range sortedMonths {
c := merged[first].months[m]
c.adult += countTrue(row, mainMonths[m])
merged[first].months[m] = c
}
}
// All non-X rows from junior tab
for _, row := range juniorRows[1:] {
if len(row) == 0 {
continue
}
first := strings.TrimSpace(row[colName])
fl := strings.ToLower(first)
if strings.Contains(fl, "# treneri") || strings.Contains(fl, "# trenéři") {
break
}
if strings.HasPrefix(first, "#") || first == "" {
continue
}
tier := ""
if len(row) > colTier {
tier = strings.ToUpper(strings.TrimSpace(row[colTier]))
}
if tier == "X" {
continue
}
if _, ok := merged[first]; !ok {
merged[first] = &struct {
tier string
months map[string]counts
}{tier: tier, months: make(map[string]counts)}
}
for _, m := range sortedMonths {
c := merged[first].months[m]
c.junior += countTrue(row, jrMonths[m])
merged[first].months[m] = c
}
}
var members []reconcile.Member
for name, data := range merged {
feeMap := make(map[string]reconcile.FeeData, len(sortedMonths))
for _, m := range sortedMonths {
c := data.months[m]
total := c.adult + c.junior
exp := fees.CalculateJuniorFee(total, m)
fee := 0
if !exp.Unknown {
fee = exp.Value
}
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
}
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
}
return members, sortedMonths, nil
}
// ---------------------------------------------------------------------------
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
// ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
if len(rows) == 0 {
return nil, nil
}
header := rows[0]
idx := func(label string) int {
label = strings.ToLower(strings.TrimSpace(label))
for i, h := range header {
if strings.ToLower(strings.TrimSpace(fmt.Sprint(h))) == label {
return i
}
}
return -1
}
idxDate := idx("date")
idxAmount := idx("amount")
idxPerson := idx("person")
idxPurpose := idx("purpose")
idxInferred := idx("inferred amount")
idxSender := idx("sender")
idxMessage := idx("message")
for _, label := range []string{"date", "amount", "person", "purpose"} {
if idx(label) == -1 {
return nil, fmt.Errorf("payments sheet missing required column %q", label)
}
}
getVal := func(row []any, i int) string {
if i < 0 || i >= len(row) {
return ""
}
return fmt.Sprint(row[i])
}
var txns []reconcile.Transaction
for _, row := range rows[1:] {
dateStr := matching.FormatDate(getVal(row, idxDate))
amountRaw := row[idxAmount]
if idxAmount < 0 || idxAmount >= len(row) {
amountRaw = ""
}
amount := parseFloat(amountRaw)
var inferredAmount *float64
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
if f := parseFloat(iv); f != 0 {
inferredAmount = &f
}
}
txns = append(txns, reconcile.Transaction{
Date: dateStr,
Amount: amount,
Person: getVal(row, idxPerson),
Purpose: getVal(row, idxPurpose),
InferredAmount: inferredAmount,
Sender: getVal(row, idxSender),
Message: getVal(row, idxMessage),
})
}
return txns, nil
}
func parseFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case string:
f, _ := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f
}
return 0
}
// ---------------------------------------------------------------------------
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
// ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
out := make(map[reconcile.ExceptionKey]reconcile.Exception)
for _, row := range rows {
if len(row) < 3 {
continue
}
name := strings.TrimSpace(fmt.Sprint(row[0]))
if strings.ToLower(name) == "name" || strings.HasPrefix(strings.ToLower(name), "name") {
continue
}
period := strings.TrimSpace(fmt.Sprint(row[1]))
amountStr := fmt.Sprint(row[2])
amount, err := strconv.Atoi(strings.TrimSpace(amountStr))
if err != nil {
continue
}
note := ""
if len(row) > 3 {
note = strings.TrimSpace(fmt.Sprint(row[3]))
}
key := reconcile.ExceptionKey{
Name: czech.Normalize(name),
Period: czech.Normalize(period),
}
out[key] = reconcile.Exception{Amount: amount, Note: note}
}
return out
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,198 @@
package membership
import (
"context"
"fuj-management/go/internal/config"
"fuj-management/go/internal/io/attendance"
"fuj-management/go/internal/io/cache"
"fuj-management/go/internal/io/drive"
"fuj-management/go/internal/io/sheets"
"testing"
"time"
)
// buildSources wires a realSources with in-memory fakes and a no-TTL cache.
func buildSources(t *testing.T, att *attendance.Fake, sh *sheets.Fake) *realSources {
t.Helper()
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{
config.AttendanceSheetID: "t1",
config.PaymentsSheetID: "t1",
}}
fc := cache.New(d, dir, config.CacheSheetMap, 0, 0)
return &realSources{attendance: att, sheets: sh, cache: fc}
}
var minimalAdultCSV = [][]string{
{"Jméno", "Tier", "", "", "01.09.2025", "08.09.2025"},
{"Alice", "A", "", "", "TRUE", "TRUE"},
{"Bob", "A", "", "", "TRUE", "FALSE"},
{"# last line"},
}
// minimalJuniorCSV has dates in October because the junior merged-month map sends
// 2025-09 → 2025-10, so two columns for 01.10.2025 and 08.10.2025 land in "2025-10".
var minimalJuniorCSV = [][]string{
{"Jméno", "Tier", "", "", "01.10.2025", "08.10.2025"},
{"Charlie", "J", "", "", "TRUE", "TRUE"},
{"# Trenéři"},
{"Coach", "X", "", "", "FALSE", "FALSE"},
}
func TestLoadAdults(t *testing.T) {
s := buildSources(t, &attendance.Fake{Adults: minimalAdultCSV}, &sheets.Fake{})
members, months, err := s.LoadAdults(context.Background())
if err != nil {
t.Fatal(err)
}
// adultMergedMonths is empty so 2025-09 stays as-is
if len(months) != 1 || months[0] != "2025-09" {
t.Errorf("unexpected months: %v", months)
}
if len(members) != 2 {
t.Fatalf("want 2 members, got %d", len(members))
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Attendance
}
if byName["Alice"] != 2 {
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
}
if byName["Bob"] != 1 {
t.Errorf("Bob: want 1 session, got %d", byName["Bob"])
}
}
func TestLoadAdults_Fee(t *testing.T) {
s := buildSources(t, &attendance.Fake{Adults: minimalAdultCSV}, &sheets.Fake{})
members, _, err := s.LoadAdults(context.Background())
if err != nil {
t.Fatal(err)
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Expected
}
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
if byName["Alice"] != 750 {
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
}
// 1 session → AdultFeeSingle = 200
if byName["Bob"] != 200 {
t.Errorf("Bob fee: want 200, got %d", byName["Bob"])
}
}
func TestLoadJuniors(t *testing.T) {
s := buildSources(t,
&attendance.Fake{Adults: minimalAdultCSV, Juniors: minimalJuniorCSV},
&sheets.Fake{})
members, months, err := s.LoadJuniors(context.Background())
if err != nil {
t.Fatal(err)
}
if len(months) == 0 {
t.Fatal("want months, got none")
}
found := false
for _, m := range members {
if m.Name == "Charlie" {
found = true
// Charlie has 2 sessions in 2025-10 (October dates in junior CSV)
if m.Fees["2025-10"].Attendance != 2 {
t.Errorf("Charlie 2025-10 attendance: want 2, got %d", m.Fees["2025-10"].Attendance)
}
}
}
if !found {
t.Error("Charlie not found in juniors")
}
}
func TestLoadTransactions(t *testing.T) {
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
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"},
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
txns, err := s.LoadTransactions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(txns) != 2 {
t.Fatalf("want 2 transactions, got %d", len(txns))
}
if txns[0].Person != "Alice" {
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
}
if txns[0].Amount != 700 {
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
}
}
func TestLoadExceptions(t *testing.T) {
excKey := config.PaymentsSheetID + "/'exceptions'!A2:D"
sh := &sheets.Fake{Values: map[string][][]any{
excKey: {
{"Alice", "2026-04", 350, "reduced"},
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
exc, err := s.LoadExceptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(exc) != 1 {
t.Fatalf("want 1 exception, got %d", len(exc))
}
for k, v := range exc {
if v.Amount != 350 {
t.Errorf("exception amount: want 350, got %d (key=%v)", v.Amount, k)
}
if v.Note != "reduced" {
t.Errorf("exception note: want 'reduced', got %q", v.Note)
}
}
}
// TTL smoke test: second call within TTL must not call fetch again.
func TestLoadAdults_CacheHit(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{config.AttendanceSheetID: "t1"}}
fc := cache.New(d, dir, config.CacheSheetMap, time.Minute, time.Minute)
calls := 0
att := &countingFetcher{rows: minimalAdultCSV, calls: &calls}
s := &realSources{attendance: att, sheets: &sheets.Fake{}, cache: fc}
if _, _, err := s.LoadAdults(context.Background()); err != nil {
t.Fatal(err)
}
if _, _, err := s.LoadAdults(context.Background()); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Errorf("want 1 fetch (cache hit on 2nd call), got %d", calls)
}
}
type countingFetcher struct {
rows [][]string
calls *int
}
func (f *countingFetcher) FetchAdults(_ context.Context) ([][]string, error) {
*f.calls++
return f.rows, nil
}
func (f *countingFetcher) FetchJuniors(_ context.Context) ([][]string, error) { return nil, nil }