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>
176 lines
4.1 KiB
Go
176 lines
4.1 KiB
Go
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)
|
|
}
|