feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands
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>
This commit is contained in:
89
go/internal/services/membership/format_fees.go
Normal file
89
go/internal/services/membership/format_fees.go
Normal file
@@ -0,0 +1,89 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user