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:
192
go/internal/services/membership/format_reconcile.go
Normal file
192
go/internal/services/membership/format_reconcile.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/domain/reconcile"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// printReconcileReport writes the full balance report to w.
|
||||
// Mirrors scripts/match_payments.py print_report().
|
||||
//
|
||||
// Verify with:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
||||
// ...'
|
||||
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
||||
monthLabel := func(m string) string {
|
||||
t, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
return t.Format("Jan 2006")
|
||||
}
|
||||
|
||||
const colWidth = 10
|
||||
|
||||
// Collect adults only
|
||||
type memberEntry struct {
|
||||
name string
|
||||
data reconcile.MemberResult
|
||||
}
|
||||
var adults []memberEntry
|
||||
for name, data := range result.Members {
|
||||
if data.Tier == "A" {
|
||||
adults = append(adults, memberEntry{name: name, data: data})
|
||||
}
|
||||
}
|
||||
sort.Slice(adults, func(i, j int) bool { return adults[i].name < adults[j].name })
|
||||
|
||||
// Header banner
|
||||
fmt.Fprintln(w, strings.Repeat("=", 80))
|
||||
fmt.Fprintln(w, "PAYMENT RECONCILIATION REPORT")
|
||||
fmt.Fprintln(w, strings.Repeat("=", 80))
|
||||
|
||||
// Name column width
|
||||
nameWidth := 20
|
||||
for _, e := range adults {
|
||||
if len(e.name) > nameWidth {
|
||||
nameWidth = len(e.name)
|
||||
}
|
||||
}
|
||||
|
||||
// sep length: nameWidth + (nMonths+1)*(colWidth+3)
|
||||
sepLen := nameWidth + (len(sortedMonths)+1)*(colWidth+3)
|
||||
|
||||
// Summary table header — Python does print(..., end="") then print(f" | {'Balance':>10}")
|
||||
fmt.Fprintf(w, "\n%-*s", nameWidth, "Member")
|
||||
for _, m := range sortedMonths {
|
||||
fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m))
|
||||
}
|
||||
fmt.Fprintf(w, " | %*s\n", colWidth, "Balance")
|
||||
fmt.Fprintln(w, strings.Repeat("-", sepLen))
|
||||
|
||||
var totalExpected, totalPaid int
|
||||
|
||||
for _, e := range adults {
|
||||
fmt.Fprintf(w, "%-*s", nameWidth, e.name)
|
||||
memberBalance := 0
|
||||
for _, m := range sortedMonths {
|
||||
md := e.data.Months[m]
|
||||
expected := md.Expected
|
||||
paid := int(md.Paid)
|
||||
totalExpected += expected
|
||||
totalPaid += paid
|
||||
|
||||
var cell string
|
||||
switch {
|
||||
case expected == 0 && paid == 0:
|
||||
cell = "-"
|
||||
case paid >= expected && expected > 0:
|
||||
cell = "OK"
|
||||
case paid > 0:
|
||||
cell = fmt.Sprintf("%d/%d", paid, expected)
|
||||
default:
|
||||
cell = fmt.Sprintf("UNPAID %d", expected)
|
||||
}
|
||||
|
||||
memberBalance += paid - expected
|
||||
fmt.Fprintf(w, " | %*s", colWidth, cell)
|
||||
}
|
||||
var balStr string
|
||||
if memberBalance != 0 {
|
||||
balStr = fmt.Sprintf("%+d", memberBalance)
|
||||
} else {
|
||||
balStr = "0"
|
||||
}
|
||||
fmt.Fprintf(w, " | %*s\n", colWidth, balStr)
|
||||
}
|
||||
|
||||
// TOTAL footer
|
||||
fmt.Fprintln(w, strings.Repeat("-", sepLen))
|
||||
fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL")
|
||||
for range sortedMonths {
|
||||
fmt.Fprintf(w, " | %*s", colWidth, "")
|
||||
}
|
||||
balance := totalPaid - totalExpected
|
||||
fmt.Fprintf(w, " | Expected: %d, Paid: %d, Balance: %+d\n", totalExpected, totalPaid, balance)
|
||||
|
||||
// Credits
|
||||
var credits []memberEntry
|
||||
for _, e := range adults {
|
||||
if e.data.TotalBalance > 0 {
|
||||
credits = append(credits, e)
|
||||
}
|
||||
}
|
||||
// also non-adult members with positive balance
|
||||
for name, data := range result.Members {
|
||||
if data.Tier != "A" && data.TotalBalance > 0 {
|
||||
credits = append(credits, memberEntry{name: name, data: data})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].name < credits[j].name })
|
||||
if len(credits) > 0 {
|
||||
fmt.Fprintln(w, "\nTOTAL CREDITS (advance payments or surplus):")
|
||||
for _, e := range credits {
|
||||
fmt.Fprintf(w, " %s: %d CZK\n", e.name, e.data.TotalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
// Debts
|
||||
var debts []memberEntry
|
||||
for _, e := range adults {
|
||||
if e.data.TotalBalance < 0 {
|
||||
debts = append(debts, e)
|
||||
}
|
||||
}
|
||||
for name, data := range result.Members {
|
||||
if data.Tier != "A" && data.TotalBalance < 0 {
|
||||
debts = append(debts, memberEntry{name: name, data: data})
|
||||
}
|
||||
}
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].name < debts[j].name })
|
||||
if len(debts) > 0 {
|
||||
fmt.Fprintln(w, "\nTOTAL DEBTS (missing payments):")
|
||||
for _, e := range debts {
|
||||
fmt.Fprintf(w, " %s: %d CZK\n", e.name, -e.data.TotalBalance)
|
||||
}
|
||||
}
|
||||
|
||||
// Unmatched transactions
|
||||
if len(result.Unmatched) > 0 {
|
||||
fmt.Fprintln(w, "\nUNMATCHED TRANSACTIONS (need manual review)")
|
||||
fmt.Fprintf(w, " %-12s %10s %-30s %s\n", "Date", "Amount", "Sender", "Message")
|
||||
fmt.Fprintf(w, " %-12s %10s %-30s %-30s\n",
|
||||
strings.Repeat("-", 12), strings.Repeat("-", 10),
|
||||
strings.Repeat("-", 30), strings.Repeat("-", 30))
|
||||
for _, tx := range result.Unmatched {
|
||||
fmt.Fprintf(w, " %-12s %10.0f %-30s %s\n",
|
||||
tx.Date, tx.Amount, tx.Sender, tx.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// Matched transaction details
|
||||
fmt.Fprintln(w, "\nMATCHED TRANSACTION DETAILS")
|
||||
for _, e := range adults {
|
||||
hasPayments := false
|
||||
for _, m := range sortedMonths {
|
||||
if len(e.data.Months[m].Transactions) > 0 {
|
||||
hasPayments = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPayments {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "\n %s:\n", e.name)
|
||||
for _, m := range sortedMonths {
|
||||
for _, tx := range e.data.Months[m].Transactions {
|
||||
conf := ""
|
||||
if tx.Confidence == "review" {
|
||||
conf = " [REVIEW]"
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %.0f CZK from %s — \"%s\"%s\n",
|
||||
monthLabel(m), tx.Amount, tx.Sender, tx.Message, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user