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>
239 lines
5.5 KiB
Go
239 lines
5.5 KiB
Go
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
|
|
}
|