fix: prevent duplicate partnumbers in stock mappings table
Add duplicate detection before batch insert of auto-mappings: - Query existing partnumbers from lot_partnumbers table - Build case-insensitive set of existing entries - Filter out duplicates before CreateInBatches - Also prevent duplicates within single batch This ensures partnumber uniqueness as primary key requires. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
175
internal/lotmatch/matcher.go
Normal file
175
internal/lotmatch/matcher.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user