package lotmatch import ( "errors" "strings" "git.mchus.pro/mchus/priceforge/internal/models" "gorm.io/gorm" ) var ( ErrResolveConflict = errors.New("multiple lot matches") ErrResolveNotFound = errors.New("lot not found") ) // MappingMatcher handles partnumber to LOT matching type MappingMatcher struct { exactMappings map[string]string // normalized partnumber -> LOT allLots []string // all LOT names sorted by length (longest first) } // NewMappingMatcherFromDB loads mappings and lots from database func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) { var mappings []models.LotPartnumber if err := db.Find(&mappings).Error; err != nil { return nil, err } var lots []models.Lot if err := db.Select("lot_name").Find(&lots).Error; err != nil { return nil, err } return NewMappingMatcher(mappings, lots), nil } // NewMappingMatcher creates a new matcher from mappings and lots func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher { // Build exact mappings map exactMappings := make(map[string]string, len(mappings)) for _, m := range mappings { pn := NormalizeKey(m.Partnumber) lot := strings.TrimSpace(m.LotName) if pn != "" && lot != "" { // Store first mapping only (ignore duplicates) if _, exists := exactMappings[pn]; !exists { exactMappings[pn] = lot } } } // Build sorted LOT list (longest first for prefix matching) allLots := make([]string, 0, len(lots)) for _, l := range lots { name := strings.TrimSpace(l.LotName) if name != "" { allLots = append(allLots, name) } } // Sort by length descending, then alphabetically for i := 0; i < len(allLots); i++ { for j := i + 1; j < len(allLots); j++ { li := len([]rune(allLots[i])) lj := len([]rune(allLots[j])) if lj > li || (lj == li && allLots[j] < allLots[i]) { allLots[i], allLots[j] = allLots[j], allLots[i] } } } return &MappingMatcher{ exactMappings: exactMappings, allLots: allLots, } } // MatchLots returns all matching LOTs for a partnumber // Returns empty slice if no match, or multiple LOTs if there's a conflict func (m *MappingMatcher) MatchLots(partnumber string) []string { if m == nil { return nil } key := NormalizeKey(partnumber) if key == "" { return nil } // Check exact mapping first if lot, ok := m.exactMappings[key]; ok { return []string{lot} } // No exact mapping found return nil } // FindPrefixMatch finds the longest LOT that partnumber starts with // Returns empty string if no match found func (m *MappingMatcher) FindPrefixMatch(partnumber string) string { if m == nil { return "" } key := NormalizeKey(partnumber) if key == "" { return "" } // Find longest matching prefix for _, lot := range m.allLots { lotKey := NormalizeKey(lot) if lotKey != "" && strings.HasPrefix(key, lotKey) { return lot } } return "" } // Resolve resolves a partnumber to a single LOT // Returns LOT name, method ("mapping", "prefix", "exact"), and error func (m *MappingMatcher) Resolve(partnumber string) (string, string, error) { if m == nil { return "", "", ErrResolveNotFound } key := NormalizeKey(partnumber) if key == "" { return "", "", ErrResolveNotFound } // Check exact mapping first if lot, ok := m.exactMappings[key]; ok { return lot, "mapping", nil } // Check if partnumber exactly matches a LOT for _, lot := range m.allLots { if NormalizeKey(lot) == key { return lot, "exact", nil } } // Try prefix match if matchedLot := m.FindPrefixMatch(partnumber); matchedLot != "" { return matchedLot, "prefix", nil } return "", "", ErrResolveNotFound } // NewLotResolverFromDB is an alias for NewMappingMatcherFromDB for backward compatibility func NewLotResolverFromDB(db *gorm.DB) (*MappingMatcher, error) { return NewMappingMatcherFromDB(db) } // NormalizeKey normalizes a string for matching (lowercase, no spaces/special chars) func NormalizeKey(v string) string { s := strings.ToLower(strings.TrimSpace(v)) replacer := strings.NewReplacer( " ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "", ) return replacer.Replace(s) }