feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults, ServeJuniors, ServePayments, ServeVersion - Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance, domain-to-wire converters, ensureSlice generic helper - Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring scripts/views.py:build_adults_view_model - Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown - Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket - Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance - Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field - Export membership.AdultMergedMonths and JuniorMergedMonths - Update sources.go to propagate new FeeData fields and parse extra transaction columns - Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns - Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user