New go/internal/domain/matching package porting three helpers from scripts/match_payments.py: - BuildNameVariants: normalized ASCII variants from a member name (nickname in parens, last/first split, len<3 filtered); variants[0] is always the full base name — MatchMembers relies on this invariant. - MatchMembers: auto/review confidence matching with an exact-name short-circuit pass that prevents nickname substrings (tov) from firing inside longer surnames (ottova); common-surname filter for review tier. - FormatDate: nil/empty/""/serial int/float64 (since 1899-12-30, fractional days supported)/YYYY-MM-DD passthrough/garbage → never errors. - InferTransactionDetails: composes BuildNameVariants+MatchMembers+ ParseMonthReferences; falls back to sender-only member match and date-derived month when text carries no signal. 21 table-driven tests; all expected values verified against live Python on 2026-05-06. go-build, go-test, go-lint all clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
2.5 KiB
Go
90 lines
2.5 KiB
Go
package matching
|
|
|
|
import (
|
|
"fmt"
|
|
"fuj-management/go/internal/domain/czech"
|
|
"time"
|
|
)
|
|
|
|
// Transaction is the subset of a payment row used by InferTransactionDetails.
|
|
// Date accepts string ("YYYY-MM-DD"), float64 (Sheets serial), or int — matching
|
|
// the heterogeneous types returned by the Sheets API and the FIO scraper.
|
|
type Transaction struct {
|
|
Sender string
|
|
Message string
|
|
UserID string
|
|
Date any
|
|
}
|
|
|
|
// InferredDetails is the result of InferTransactionDetails.
|
|
type InferredDetails struct {
|
|
Members []Match
|
|
Months []string
|
|
SearchText string
|
|
}
|
|
|
|
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
|
|
//
|
|
// Search text for member matching: sender + message + user_id.
|
|
// Month search text: message + user_id only (sender excluded, matching Python).
|
|
// Fallback 1: if no members found, retry match on sender alone.
|
|
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
|
|
//
|
|
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
|
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
|
//
|
|
// Ports scripts/match_payments.py infer_transaction_details.
|
|
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
|
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
|
|
|
members := MatchMembers(searchText, memberNames)
|
|
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
|
|
|
|
if len(members) == 0 {
|
|
members = MatchMembers(tx.Sender, memberNames)
|
|
}
|
|
|
|
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
|
|
if ym := inferMonthFromDate(tx.Date); ym != "" {
|
|
months = []string{ym}
|
|
}
|
|
}
|
|
|
|
if months == nil {
|
|
months = []string{}
|
|
}
|
|
|
|
return InferredDetails{
|
|
Members: members,
|
|
Months: months,
|
|
SearchText: searchText,
|
|
}
|
|
}
|
|
|
|
// inferMonthFromDate converts a date value to "YYYY-MM" for the month fallback.
|
|
// Returns "" on any error, matching Python's bare except pass.
|
|
func inferMonthFromDate(val any) string {
|
|
switch v := val.(type) {
|
|
case int:
|
|
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case int64:
|
|
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case float64:
|
|
dt := sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case string:
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
dt, err := time.Parse("2006-01-02", v)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return dt.Format("2006-01")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|