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.QRAccount, 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} }