Files
PriceForge/internal/lotmatch/matcher.go
2026-03-07 23:11:42 +03:00

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