package banksync import ( "context" "fmt" "fuj-management/go/internal/domain/matching" "fuj-management/go/internal/domain/reconcile" "fuj-management/go/internal/io/sheets" "strings" "time" ) // InferOpts controls infer behaviour. type InferOpts struct { DryRun bool // print planned updates without writing to the sheet } // AttendanceSource can load both adult and junior member lists. type AttendanceSource interface { LoadAdults(ctx context.Context) ([]reconcile.Member, []string, error) LoadJuniors(ctx context.Context) ([]reconcile.Member, []string, error) } // sheetReadWriter is the subset of *sheets.Client used by InferPayments. type sheetReadWriter interface { GetValues(ctx context.Context, spreadsheetID, a1Range string) ([][]any, error) BatchUpdateValues(ctx context.Context, spreadsheetID string, updates []sheets.ValueRange) error } // InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments // sheet using name and month matching against the member list. // Returns the number of rows updated (or that would be updated on dry-run). // Ports scripts/infer_payments.py infer_payments. func InferPayments( ctx context.Context, spreadsheetID string, sh sheetReadWriter, attendance AttendanceSource, opts InferOpts, ) (int, error) { rows, err := sh.GetValues(ctx, spreadsheetID, "A1:Z") if err != nil { return 0, fmt.Errorf("infer: read sheet: %w", err) } if len(rows) == 0 { return 0, nil } header := rows[0] colIdx := func(label string) int { label = strings.ToLower(strings.TrimSpace(label)) for i, h := range header { if strings.ToLower(strings.TrimSpace(fmt.Sprint(h))) == label { return i } } return -1 } idxDate := colIdx("date") idxAmount := colIdx("amount") idxSender := colIdx("sender") idxMessage := colIdx("message") idxVS := colIdx("vs") idxManual := colIdx("manual fix") idxPerson := colIdx("person") idxPurpose := colIdx("purpose") idxInferred := colIdx("inferred amount") for _, req := range []string{"person", "purpose", "inferred amount"} { if colIdx(req) == -1 { return 0, fmt.Errorf("infer: required column %q not found in sheet", req) } } // Build union member list: adults + juniors, deduped by canonical key. adults, _, err := attendance.LoadAdults(ctx) if err != nil { return 0, fmt.Errorf("infer: load adults: %w", err) } juniors, _, err := attendance.LoadJuniors(ctx) if err != nil { return 0, fmt.Errorf("infer: load juniors: %w", err) } memberNames := dedupeMembers(append(adults, juniors...)) defaultYear := time.Now().Year() var updates []sheets.ValueRange for i, row := range rows[1:] { rowNum := i + 2 // 1-based, skip header get := func(idx int) string { if idx < 0 || idx >= len(row) { return "" } return strings.TrimSpace(fmt.Sprint(row[idx])) } // Skip rule: any of manual fix / Person / Purpose non-empty → leave alone if get(idxManual) != "" || get(idxPerson) != "" || get(idxPurpose) != "" { continue } tx := matching.Transaction{ Sender: get(idxSender), Message: get(idxMessage), UserID: get(idxVS), } if idxDate >= 0 && idxDate < len(row) { tx.Date = row[idxDate] } inferred := matching.InferTransactionDetails(tx, memberNames, defaultYear) if len(inferred.Members) == 0 && len(inferred.Months) == 0 { continue } var peeps []string for _, m := range inferred.Members { if m.Confidence == matching.ConfidenceReview { peeps = append(peeps, "[?] "+m.Name) } else { peeps = append(peeps, m.Name) } } personVal := strings.Join(peeps, ", ") purposeVal := strings.Join(inferred.Months, ", ") amountVal := "" if idxAmount >= 0 && idxAmount < len(row) { amountVal = fmt.Sprint(row[idxAmount]) } if opts.DryRun { fmt.Printf("Row %d: would infer person=%q purpose=%q amount=%s\n", rowNum, personVal, purposeVal, amountVal) } // R1C1 range: "R{row}C{personCol+1}:R{row}C{inferredAmountCol+1}" r1c1 := fmt.Sprintf("R%dC%d:R%dC%d", rowNum, idxPerson+1, rowNum, idxInferred+1) updates = append(updates, sheets.ValueRange{ Range: r1c1, Values: [][]any{{personVal, purposeVal, amountVal}}, }) } if len(updates) == 0 || opts.DryRun { return len(updates), nil } if err := sh.BatchUpdateValues(ctx, spreadsheetID, updates); err != nil { return 0, fmt.Errorf("infer: batch update: %w", err) } return len(updates), nil } // dedupeMembers returns unique member names, deduped by canonical key. func dedupeMembers(members []reconcile.Member) []string { seen := make(map[string]bool, len(members)) var names []string for _, m := range members { key := strings.Join(strings.Fields(m.Name), " ") if !seen[key] { seen[key] = true names = append(names, m.Name) } } return names }