Files
PriceForge/internal/lotmatch/matcher.go
Michael Chus ed339a172b 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>
2026-02-08 11:58:13 +03:00

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)
}