package lotmatch import ( "encoding/json" "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") ) // MappingMatcher handles partnumber to LOT matching type MappingMatcher struct { exactMappings map[string]string // normalized partnumber -> first LOT (for Resolve) exactLots map[string][]string // normalized partnumber -> all LOTs (for MatchLots) exactLotName map[string]string // normalized LOT -> LOT wildcards []wildcardMapping allLots []string // all LOT names sorted by length (longest first) vendorExactMappings map[string]map[string]string // normalized vendor -> normalized partnumber -> lot catalogLots map[string][]models.PartnumberBookLot } // LotResolver preserves legacy resolve behavior used by older tests and flows. type LotResolver struct { partnumberToLots map[string][]string exactLots map[string]string allLots []string } type wildcardMapping struct { lotName string re *regexp.Regexp } // NewMappingMatcherFromDB loads mappings and lots from database func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) { var mappings []models.LotPartnumber if db != nil && db.Migrator().HasTable(models.LotPartnumber{}.TableName()) { if err := db.Find(&mappings).Error; err != nil && !isMissingTableError(err) { return nil, err } } var lots []models.Lot if err := db.Select("lot_name").Find(&lots).Error; err != nil { return nil, err } catalogLots, err := loadCatalogLots(db) if err != nil { return nil, err } return NewMappingMatcherWithCatalog(mappings, lots, catalogLots), nil } // NewMappingMatcher creates a new matcher from mappings and lots func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher { return NewMappingMatcherWithCatalog(mappings, lots, nil) } func NewMappingMatcherWithCatalog(mappings []models.LotPartnumber, lots []models.Lot, catalogLots map[string][]models.PartnumberBookLot) *MappingMatcher { // Build exact mappings map exactMappings := make(map[string]string, len(mappings)) exactLots := make(map[string][]string, len(mappings)) vendorExactMappings := make(map[string]map[string]string) wildcards := make([]wildcardMapping, 0, len(mappings)) for _, m := range mappings { pn := NormalizeKey(m.Partnumber) lot := strings.TrimSpace(m.LotName) vendor := normalizeVendorKey(m.Vendor) if pn != "" && lot != "" { if _, ok := vendorExactMappings[vendor]; !ok { vendorExactMappings[vendor] = make(map[string]string) } vendorExactMappings[vendor][pn] = lot if strings.Contains(pn, "*") { pattern := "^" + regexp.QuoteMeta(pn) + "$" pattern = strings.ReplaceAll(pattern, "\\*", ".*") re, err := regexp.Compile(pattern) if err == nil { wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re}) } continue } exactLots[pn] = append(exactLots[pn], lot) // Store first mapping only (ignore duplicates) if _, exists := exactMappings[pn]; !exists { exactMappings[pn] = lot } } } for key := range exactLots { exactLots[key] = uniqueCaseInsensitive(exactLots[key]) } // Build sorted LOT list (longest first for prefix matching) allLots := make([]string, 0, len(lots)) exactLotName := make(map[string]string, len(lots)) for _, l := range lots { name := strings.TrimSpace(l.LotName) if name != "" { allLots = append(allLots, name) exactLotName[NormalizeKey(name)] = 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, exactLots: exactLots, exactLotName: exactLotName, wildcards: wildcards, allLots: allLots, vendorExactMappings: vendorExactMappings, catalogLots: catalogLots, } } func loadCatalogLots(db *gorm.DB) (map[string][]models.PartnumberBookLot, error) { var items []models.PartnumberBookItem if db != nil && db.Migrator().HasTable(models.PartnumberBookItem{}.TableName()) { if err := db.Order("partnumber ASC").Find(&items).Error; err != nil && !isMissingTableError(err) { return nil, err } } result := make(map[string][]models.PartnumberBookLot, len(items)) for _, item := range items { pnKey := NormalizeKey(item.Partnumber) if pnKey == "" { continue } var lots []models.PartnumberBookLot if err := json.Unmarshal([]byte(strings.TrimSpace(item.LotsJSON)), &lots); err != nil { continue } clean := make([]models.PartnumberBookLot, 0, len(lots)) for _, lot := range lots { name := strings.TrimSpace(lot.LotName) if name == "" || lot.Qty <= 0 { continue } clean = append(clean, models.PartnumberBookLot{LotName: name, Qty: lot.Qty}) } if len(clean) == 0 { continue } sort.Slice(clean, func(i, j int) bool { return clean[i].LotName < clean[j].LotName }) result[pnKey] = clean } return result, nil } // NewLotResolver creates a resolver with legacy conflict-aware behavior. func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver { partnumberToLots := make(map[string][]string, len(mappings)) for _, m := range mappings { if normalizeVendorKey(m.Vendor) != "" { continue } 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, } } // 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 { return m.MatchLotsWithVendor(partnumber, "") } // MatchLotsWithVendor returns mapped lots using vendor+partnumber with fallback to vendor-agnostic partnumber. func (m *MappingMatcher) MatchLotsWithVendor(partnumber, vendor string) []string { items := m.MatchLotItemsWithVendor(partnumber, vendor) lots := make([]string, 0, len(items)) for _, item := range items { if strings.TrimSpace(item.LotName) != "" { lots = append(lots, item.LotName) } } return uniqueCaseInsensitive(lots) } func (m *MappingMatcher) MatchLotItemsWithVendor(partnumber, vendor string) []models.PartnumberBookLot { if m == nil { return nil } key := NormalizeKey(partnumber) if key == "" { return nil } lots := make([]models.PartnumberBookLot, 0, 4) vk := normalizeVendorKey(vendor) if m.vendorExactMappings != nil { if byVendor := m.vendorExactMappings[vk]; len(byVendor) > 0 { if lot := strings.TrimSpace(byVendor[key]); lot != "" { return []models.PartnumberBookLot{{LotName: lot, Qty: 1}} } } if vk != "" { if fallback := m.vendorExactMappings[""]; len(fallback) > 0 { if lot := strings.TrimSpace(fallback[key]); lot != "" { return []models.PartnumberBookLot{{LotName: lot, Qty: 1}} } } } } if exact := m.catalogLots[key]; len(exact) > 0 { return append([]models.PartnumberBookLot(nil), exact...) } if exact := m.exactLots[key]; len(exact) > 0 { for _, lot := range exact { lots = append(lots, models.PartnumberBookLot{LotName: lot, Qty: 1}) } } for _, wc := range m.wildcards { if wc.re == nil || !wc.re.MatchString(key) { continue } lots = append(lots, models.PartnumberBookLot{LotName: wc.lotName, Qty: 1}) } if lot, ok := m.exactLotName[key]; ok && strings.TrimSpace(lot) != "" { lots = append(lots, models.PartnumberBookLot{LotName: lot, Qty: 1}) } if matchedLot := m.FindPrefixMatch(partnumber); matchedLot != "" { lots = append(lots, models.PartnumberBookLot{LotName: matchedLot, Qty: 1}) } return uniqueLotItems(lots) } // 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) { return m.ResolveWithVendor(partnumber, "") } // ResolveWithVendor resolves partnumber using vendor-first strategy and fallback to vendor-agnostic rows. func (m *MappingMatcher) ResolveWithVendor(partnumber, vendor string) (string, string, error) { if m == nil { return "", "", ErrResolveNotFound } key := NormalizeKey(partnumber) if key == "" { return "", "", ErrResolveNotFound } vk := normalizeVendorKey(vendor) if byVendor, ok := m.vendorExactMappings[vk]; ok { if lot := strings.TrimSpace(byVendor[key]); lot != "" { return lot, "mapping", nil } } if vk != "" { if fallback, ok := m.vendorExactMappings[""]; ok { if lot := strings.TrimSpace(fallback[key]); lot != "" { return lot, "mapping", nil } } } if items := m.catalogLots[key]; len(items) > 0 { if len(items) == 1 { return items[0].LotName, "mapping", nil } return "", "", ErrResolveConflict } // Check exact mapping fallback map if lot, ok := m.exactMappings[key]; ok && strings.TrimSpace(lot) != "" { 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 } func normalizeVendorKey(v string) string { return strings.ToLower(strings.TrimSpace(v)) } // Resolve returns LOT and resolve type using legacy labels: // mapping_table | article_exact | prefix 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 } // 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) } 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 } func uniqueLotItems(values []models.PartnumberBookLot) []models.PartnumberBookLot { seen := make(map[string]struct{}, len(values)) out := make([]models.PartnumberBookLot, 0, len(values)) for _, v := range values { trimmed := strings.TrimSpace(v.LotName) if trimmed == "" || v.Qty <= 0 { continue } key := strings.ToLower(trimmed) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, models.PartnumberBookLot{LotName: trimmed, Qty: v.Qty}) } return out } func isMissingTableError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist") }