package lotmatch import ( "errors" "regexp" "sort" "strings" "git.mchus.pro/mchus/priceforge/internal/models" "gorm.io/gorm" ) var ( ErrResolveConflict = errors.New("multiple lot matches") ErrResolveNotFound = errors.New("lot not found") ) type LotResolver struct { partnumberToLots map[string][]string exactLots map[string]string allLots []string } type MappingMatcher struct { exact map[string][]string exactLot map[string]string wildcard []wildcardMapping } type wildcardMapping struct { lotName string re *regexp.Regexp } func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) { mappings, lots, err := loadMappingsAndLots(db) if err != nil { return nil, err } return NewLotResolver(mappings, lots), nil } func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) { mappings, lots, err := loadMappingsAndLots(db) if err != nil { return nil, err } return NewMappingMatcher(mappings, lots), nil } func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver { partnumberToLots := make(map[string][]string, len(mappings)) for _, m := range mappings { pn := NormalizeKey(m.Partnumber) lot := strings.TrimSpace(m.LotName) if pn == "" || lot == "" { continue } partnumberToLots[pn] = append(partnumberToLots[pn], lot) } for key := range partnumberToLots { partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key]) } exactLots := make(map[string]string, len(lots)) allLots := make([]string, 0, len(lots)) for _, l := range lots { name := strings.TrimSpace(l.LotName) if name == "" { continue } exactLots[NormalizeKey(name)] = name allLots = append(allLots, name) } sort.Slice(allLots, func(i, j int) bool { li := len([]rune(allLots[i])) lj := len([]rune(allLots[j])) if li == lj { return allLots[i] < allLots[j] } return li > lj }) return &LotResolver{ partnumberToLots: partnumberToLots, exactLots: exactLots, allLots: allLots, } } func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher { exact := make(map[string][]string, len(mappings)) wildcards := make([]wildcardMapping, 0, len(mappings)) for _, m := range mappings { pn := NormalizeKey(m.Partnumber) lot := strings.TrimSpace(m.LotName) if pn == "" || lot == "" { continue } if strings.Contains(pn, "*") { pattern := "^" + regexp.QuoteMeta(pn) + "$" pattern = strings.ReplaceAll(pattern, "\\*", ".*") re, err := regexp.Compile(pattern) if err != nil { continue } wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re}) continue } exact[pn] = append(exact[pn], lot) } for key := range exact { exact[key] = uniqueCaseInsensitive(exact[key]) } exactLot := make(map[string]string, len(lots)) for _, l := range lots { name := strings.TrimSpace(l.LotName) if name == "" { continue } exactLot[NormalizeKey(name)] = name } return &MappingMatcher{ exact: exact, exactLot: exactLot, wildcard: wildcards, } } func (r *LotResolver) Resolve(partnumber string) (string, string, error) { key := NormalizeKey(partnumber) if key == "" { return "", "", ErrResolveNotFound } if mapped := r.partnumberToLots[key]; len(mapped) > 0 { if len(mapped) == 1 { return mapped[0], "mapping_table", nil } return "", "", ErrResolveConflict } if exact, ok := r.exactLots[key]; ok { return exact, "article_exact", nil } best := "" bestLen := -1 tie := false for _, lot := range r.allLots { lotKey := NormalizeKey(lot) if lotKey == "" { continue } if strings.HasPrefix(key, lotKey) { l := len([]rune(lotKey)) if l > bestLen { best = lot bestLen = l tie = false } else if l == bestLen && !strings.EqualFold(best, lot) { tie = true } } } if best == "" { return "", "", ErrResolveNotFound } if tie { return "", "", ErrResolveConflict } return best, "prefix", nil } func (m *MappingMatcher) MatchLots(partnumber string) []string { if m == nil { return nil } key := NormalizeKey(partnumber) if key == "" { return nil } lots := make([]string, 0, 2) if exact := m.exact[key]; len(exact) > 0 { lots = append(lots, exact...) } for _, wc := range m.wildcard { if wc.re == nil || !wc.re.MatchString(key) { continue } lots = append(lots, wc.lotName) } if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" { lots = append(lots, lot) } return uniqueCaseInsensitive(lots) } func NormalizeKey(v string) string { s := strings.ToLower(strings.TrimSpace(v)) replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "") return replacer.Replace(s) } func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) { var mappings []models.LotPartnumber if err := db.Find(&mappings).Error; err != nil { return nil, nil, err } var lots []models.Lot if err := db.Select("lot_name").Find(&lots).Error; err != nil { return nil, nil, err } return mappings, lots, nil } func uniqueCaseInsensitive(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, v := range values { trimmed := strings.TrimSpace(v) if trimmed == "" { continue } key := strings.ToLower(trimmed) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, trimmed) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) }) return out }