Add internal/services/membership package: AttendanceLoader, TransactionLoader, ExceptionLoader interfaces + NewStubSources stub (returns ErrIOPending until M4 lands real Sheets loaders). FeesReport and ReconcileReport orchestrate domain/fees + domain/reconcile and write fixed-width text reports matching Python calculate_fees.py and match_payments.py print_report output. 13 unit tests cover all formatter branches and orchestration wiring via fake loaders. cmd/fuj/main.go: fees and reconcile subcommands now dispatch; sync/infer retain the [M4] placeholder. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
2.0 KiB
Go
90 lines
2.0 KiB
Go
package membership
|
|
|
|
import (
|
|
"fmt"
|
|
"fuj-management/go/internal/domain/reconcile"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// printFeesTable writes a fixed-width adult-fees table to w.
|
|
// Mirrors scripts/calculate_fees.py main().
|
|
//
|
|
// Verify with:
|
|
//
|
|
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
|
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
|
type row struct {
|
|
name string
|
|
fees map[string]reconcile.FeeData
|
|
}
|
|
|
|
var adults []row
|
|
for _, m := range members {
|
|
if m.Tier == "A" {
|
|
adults = append(adults, row{name: m.Name, fees: m.Fees})
|
|
}
|
|
}
|
|
|
|
if len(adults) == 0 {
|
|
fmt.Fprintln(w, "No data.")
|
|
return
|
|
}
|
|
|
|
monthLabel := func(m string) string {
|
|
t, err := time.Parse("2006-01", m)
|
|
if err != nil {
|
|
return m
|
|
}
|
|
return t.Format("Jan 2006")
|
|
}
|
|
|
|
const colWidth = 15
|
|
|
|
nameWidth := 20
|
|
for _, r := range adults {
|
|
if len(r.name) > nameWidth {
|
|
nameWidth = len(r.name)
|
|
}
|
|
}
|
|
|
|
// separator length: nameWidth + N*(colWidth+3) where +3 is " | "
|
|
sepLen := nameWidth + len(sortedMonths)*(colWidth+3)
|
|
|
|
// Header row
|
|
fmt.Fprintf(w, "%-*s", nameWidth, "Member")
|
|
for _, m := range sortedMonths {
|
|
fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m))
|
|
}
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, strings.Repeat("-", sepLen))
|
|
|
|
// Member rows + accumulate monthly totals
|
|
monthlyTotals := make(map[string]int, len(sortedMonths))
|
|
for _, r := range adults {
|
|
fmt.Fprintf(w, "%-*s", nameWidth, r.name)
|
|
for _, m := range sortedMonths {
|
|
fd := r.fees[m]
|
|
monthlyTotals[m] += fd.Expected
|
|
var cell string
|
|
if fd.Attendance > 0 {
|
|
cell = fmt.Sprintf("%d CZK (%d)", fd.Expected, fd.Attendance)
|
|
} else {
|
|
cell = "-"
|
|
}
|
|
fmt.Fprintf(w, " | %*s", colWidth, cell)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
|
|
// Totals row
|
|
fmt.Fprintln(w, strings.Repeat("-", sepLen))
|
|
fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL")
|
|
for _, m := range sortedMonths {
|
|
cell := fmt.Sprintf("%d CZK", monthlyTotals[m])
|
|
fmt.Fprintf(w, " | %*s", colWidth, cell)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|