484 lines
13 KiB
Go
484 lines
13 KiB
Go
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")
|
|
}
|