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>
407 lines
12 KiB
Go
407 lines
12 KiB
Go
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
|
|
package reconcile
|
|
|
|
import (
|
|
"fuj-management/go/internal/domain/czech"
|
|
"fuj-management/go/internal/domain/matching"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// ExceptionKey identifies a fee override by normalized member name and period.
|
|
type ExceptionKey struct {
|
|
Name string // czech.Normalize(memberName)
|
|
Period string // czech.Normalize("YYYY-MM")
|
|
}
|
|
|
|
// Exception is a manual fee override for one member in one period.
|
|
type Exception struct {
|
|
Amount int
|
|
Note string
|
|
}
|
|
|
|
// FeeData holds the expected fee and attendance data for one member in one month.
|
|
type FeeData struct {
|
|
Expected int
|
|
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
|
|
Attendance int
|
|
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
|
|
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
|
|
}
|
|
|
|
// Member is one row from the attendance sheet.
|
|
type Member struct {
|
|
Name string
|
|
Tier string
|
|
Fees map[string]FeeData // month ("YYYY-MM") → fee data
|
|
}
|
|
|
|
// Transaction is one payment row from the payments sheet.
|
|
// Date must already be a "YYYY-MM-DD" string (convert with matching.FormatDate before calling).
|
|
// InferredAmount, when non-nil, replaces Amount when person and purpose are pre-matched.
|
|
type Transaction struct {
|
|
Date string
|
|
Amount float64
|
|
ManualFix string // "manual fix" column; non-empty disables re-inference
|
|
Person string // comma-separated canonical names (empty → use inference)
|
|
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
|
InferredAmount *float64 // nil → fall back to Amount
|
|
Sender string
|
|
VS string // Variabilní symbol (Czech variable payment symbol)
|
|
Message string
|
|
BankID string
|
|
SyncID string
|
|
UserID string
|
|
}
|
|
|
|
// TxEntry is the portion of a payment allocated to a single member+month.
|
|
type TxEntry struct {
|
|
Amount float64
|
|
Date string
|
|
Sender string
|
|
Message string
|
|
Confidence string
|
|
}
|
|
|
|
// OtherEntry is a payment with purpose "other:…" allocated to a member.
|
|
type OtherEntry struct {
|
|
Amount float64
|
|
Date string
|
|
Sender string
|
|
Message string
|
|
Purpose string
|
|
Confidence string
|
|
}
|
|
|
|
// MonthData is the ledger state for one member in one month.
|
|
type MonthData struct {
|
|
Expected int
|
|
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
|
OriginalExpected int
|
|
AttendanceCount int
|
|
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
|
|
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
|
|
Exception *Exception
|
|
Paid float64
|
|
Transactions []TxEntry
|
|
}
|
|
|
|
// MemberResult is the reconciled ledger for one member.
|
|
type MemberResult struct {
|
|
Tier string
|
|
Months map[string]MonthData
|
|
OtherTransactions []OtherEntry
|
|
TotalBalance int
|
|
}
|
|
|
|
// Result is the top-level output of Reconcile.
|
|
type Result struct {
|
|
Members map[string]MemberResult
|
|
Unmatched []Transaction
|
|
Credits map[string]int // final balance for every member (may be negative)
|
|
}
|
|
|
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
|
|
|
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
|
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
|
// Ports scripts/match_payments.py canonical_member_key.
|
|
func canonicalMemberKey(name string) string {
|
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
|
}
|
|
|
|
type monthExpected struct {
|
|
month string
|
|
expected int
|
|
}
|
|
|
|
// Reconcile matches transactions to members and months using three allocation phases:
|
|
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
|
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
|
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
|
//
|
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
|
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
|
//
|
|
// Ports scripts/match_payments.py reconcile.
|
|
func Reconcile(
|
|
members []Member,
|
|
sortedMonths []string,
|
|
transactions []Transaction,
|
|
exceptions map[ExceptionKey]Exception,
|
|
defaultYear int,
|
|
) Result {
|
|
memberNames := make([]string, len(members))
|
|
memberTiers := make(map[string]string, len(members))
|
|
memberFees := make(map[string]map[string]FeeData, len(members))
|
|
|
|
for i, m := range members {
|
|
memberNames[i] = m.Name
|
|
memberTiers[m.Name] = m.Tier
|
|
memberFees[m.Name] = m.Fees
|
|
}
|
|
|
|
// Map canonical key → first attendance-sheet name with that key, so Person cells
|
|
// that drift in diacritics/case/whitespace still resolve to the canonical name.
|
|
canonicalByKey := make(map[string]string, len(memberNames))
|
|
for _, name := range memberNames {
|
|
key := canonicalMemberKey(name)
|
|
if _, exists := canonicalByKey[key]; !exists {
|
|
canonicalByKey[key] = name
|
|
}
|
|
}
|
|
|
|
if exceptions == nil {
|
|
exceptions = map[ExceptionKey]Exception{}
|
|
}
|
|
|
|
// Initialise ledger
|
|
ledger := make(map[string]map[string]MonthData, len(memberNames))
|
|
otherLedger := make(map[string][]OtherEntry, len(memberNames))
|
|
|
|
for _, name := range memberNames {
|
|
ledger[name] = make(map[string]MonthData, len(sortedMonths))
|
|
otherLedger[name] = []OtherEntry{}
|
|
for _, m := range sortedMonths {
|
|
fd := memberFees[name][m]
|
|
originalExpected := fd.Expected
|
|
attendanceCount := fd.Attendance
|
|
|
|
var expected int
|
|
var exInfo *Exception
|
|
exKey := ExceptionKey{
|
|
Name: czech.Normalize(name),
|
|
Period: czech.Normalize(m),
|
|
}
|
|
if ex, ok := exceptions[exKey]; ok {
|
|
expected = ex.Amount
|
|
exCopy := ex
|
|
exInfo = &exCopy
|
|
} else {
|
|
expected = originalExpected
|
|
}
|
|
|
|
ledger[name][m] = MonthData{
|
|
Expected: expected,
|
|
IsUnknown: fd.IsUnknown,
|
|
OriginalExpected: originalExpected,
|
|
AttendanceCount: attendanceCount,
|
|
JuniorAttendance: fd.JuniorAttendance,
|
|
AdultAttendance: fd.AdultAttendance,
|
|
Exception: exInfo,
|
|
Paid: 0,
|
|
Transactions: []TxEntry{},
|
|
}
|
|
}
|
|
}
|
|
|
|
var unmatched []Transaction
|
|
credits := make(map[string]int, len(memberNames))
|
|
|
|
for _, tx := range transactions {
|
|
personStr := strings.TrimSpace(tx.Person)
|
|
purposeStr := strings.TrimSpace(tx.Purpose)
|
|
personStr = questionMarkRe.ReplaceAllString(personStr, "")
|
|
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
|
|
|
|
var matchedMembers []matching.Match
|
|
var matchedMonths []string
|
|
var amount float64
|
|
|
|
if personStr != "" && purposeStr != "" {
|
|
for p := range strings.SplitSeq(personStr, ",") {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
matchedMembers = append(matchedMembers, matching.Match{
|
|
Name: p,
|
|
Confidence: matching.ConfidenceAuto,
|
|
})
|
|
}
|
|
}
|
|
if isOther {
|
|
matchedMonths = []string{purposeStr}
|
|
} else {
|
|
for m := range strings.SplitSeq(purposeStr, ",") {
|
|
m = strings.TrimSpace(m)
|
|
if m != "" {
|
|
matchedMonths = append(matchedMonths, m)
|
|
}
|
|
}
|
|
}
|
|
if tx.InferredAmount != nil {
|
|
amount = *tx.InferredAmount
|
|
} else {
|
|
amount = tx.Amount
|
|
}
|
|
} else {
|
|
// Inference fallback for rows not yet processed by infer_payments.py
|
|
inferred := matching.InferTransactionDetails(
|
|
matching.Transaction{
|
|
Sender: tx.Sender,
|
|
Message: tx.Message,
|
|
UserID: tx.UserID,
|
|
Date: tx.Date,
|
|
},
|
|
memberNames,
|
|
defaultYear,
|
|
)
|
|
matchedMembers = inferred.Members
|
|
matchedMonths = inferred.Months
|
|
amount = tx.Amount
|
|
}
|
|
|
|
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
|
|
unmatched = append(unmatched, tx)
|
|
continue
|
|
}
|
|
|
|
if isOther {
|
|
nAlloc := len(matchedMembers)
|
|
perAlloc := 0.0
|
|
if nAlloc > 0 {
|
|
perAlloc = amount / float64(nAlloc)
|
|
}
|
|
for _, m := range matchedMembers {
|
|
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
|
|
if memberName != "" {
|
|
otherLedger[memberName] = append(otherLedger[memberName], OtherEntry{
|
|
Amount: perAlloc,
|
|
Date: tx.Date,
|
|
Sender: tx.Sender,
|
|
Message: tx.Message,
|
|
Purpose: purposeStr,
|
|
Confidence: string(m.Confidence),
|
|
})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
memberShare := 0.0
|
|
if len(matchedMembers) > 0 {
|
|
memberShare = amount / float64(len(matchedMembers))
|
|
}
|
|
|
|
for _, m := range matchedMembers {
|
|
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
|
|
if memberName == "" {
|
|
unmatched = append(unmatched, tx)
|
|
continue
|
|
}
|
|
|
|
var inWindow []monthExpected
|
|
outCount := 0
|
|
for _, month := range matchedMonths {
|
|
if md, ok := ledger[memberName][month]; ok {
|
|
inWindow = append(inWindow, monthExpected{month: month, expected: md.Expected})
|
|
} else {
|
|
outCount++
|
|
}
|
|
}
|
|
|
|
nTotal := len(matchedMonths)
|
|
outCredit := 0.0
|
|
if outCount > 0 && nTotal > 0 {
|
|
outCredit = memberShare / float64(nTotal) * float64(outCount)
|
|
credits[memberName] += int(outCredit)
|
|
}
|
|
|
|
inWindowShare := memberShare - outCredit
|
|
|
|
if len(inWindow) == 0 {
|
|
continue
|
|
}
|
|
|
|
totalExpected := 0
|
|
for _, mw := range inWindow {
|
|
totalExpected += mw.expected
|
|
}
|
|
|
|
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
|
// Greedy: payment covers all expected fees; overflow → credit
|
|
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
|
for _, mw := range inWindow {
|
|
alloc := float64(mw.expected)
|
|
md := ledger[memberName][mw.month]
|
|
md.Paid += alloc
|
|
md.Transactions = append(md.Transactions, TxEntry{
|
|
Amount: alloc,
|
|
Date: tx.Date,
|
|
Sender: tx.Sender,
|
|
Message: tx.Message,
|
|
Confidence: string(m.Confidence),
|
|
})
|
|
ledger[memberName][mw.month] = md
|
|
}
|
|
} else if totalExpected > 0 {
|
|
// Proportional: distribute by each month's share; last month absorbs float remainder
|
|
remaining := inWindowShare
|
|
for i, mw := range inWindow {
|
|
var alloc float64
|
|
if i == len(inWindow)-1 {
|
|
alloc = remaining
|
|
} else {
|
|
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
|
}
|
|
remaining -= alloc
|
|
md := ledger[memberName][mw.month]
|
|
md.Paid += alloc
|
|
md.Transactions = append(md.Transactions, TxEntry{
|
|
Amount: alloc,
|
|
Date: tx.Date,
|
|
Sender: tx.Sender,
|
|
Message: tx.Message,
|
|
Confidence: string(m.Confidence),
|
|
})
|
|
ledger[memberName][mw.month] = md
|
|
}
|
|
} else {
|
|
// Even-split fallback: prepayment before attendance recorded
|
|
perMonth := inWindowShare / float64(len(inWindow))
|
|
for _, mw := range inWindow {
|
|
md := ledger[memberName][mw.month]
|
|
md.Paid += perMonth
|
|
md.Transactions = append(md.Transactions, TxEntry{
|
|
Amount: perMonth,
|
|
Date: tx.Date,
|
|
Sender: tx.Sender,
|
|
Message: tx.Message,
|
|
Confidence: string(m.Confidence),
|
|
})
|
|
ledger[memberName][mw.month] = md
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final total balances: window balance + out-of-window credits accumulated above
|
|
finalBalances := make(map[string]int, len(memberNames))
|
|
for _, name := range memberNames {
|
|
windowBalance := 0
|
|
for _, mdata := range ledger[name] {
|
|
windowBalance += int(mdata.Paid) - mdata.Expected
|
|
}
|
|
finalBalances[name] = windowBalance + credits[name]
|
|
}
|
|
|
|
membersResult := make(map[string]MemberResult, len(memberNames))
|
|
for _, name := range memberNames {
|
|
membersResult[name] = MemberResult{
|
|
Tier: memberTiers[name],
|
|
Months: ledger[name],
|
|
OtherTransactions: otherLedger[name],
|
|
TotalBalance: finalBalances[name],
|
|
}
|
|
}
|
|
|
|
if unmatched == nil {
|
|
unmatched = []Transaction{}
|
|
}
|
|
|
|
return Result{
|
|
Members: membersResult,
|
|
Unmatched: unmatched,
|
|
Credits: finalBalances,
|
|
}
|
|
}
|