Files
fuj-management/go/internal/web/api/build_common.go
Jan Novak 7d48e8f607
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
- 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>
2026-05-07 20:13:38 +02:00

185 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}