diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 7c561b8..8a1af06 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -1007,7 +1007,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "deleted": result.Deleted, "unmapped": result.Unmapped, "conflicts": result.Conflicts, - "fallback_matches": result.FallbackMatches, + "auto_mapped": result.AutoMapped, "parse_errors": result.ParseErrors, "qty_parse_errors": result.QtyParseErrors, "ignored": result.Ignored, @@ -1042,7 +1042,7 @@ func (h *PricingHandler) ImportStockLog(c *gin.Context) { "deleted": p.Deleted, "unmapped": p.Unmapped, "conflicts": p.Conflicts, - "fallback_matches": p.FallbackMatches, + "auto_mapped": p.AutoMapped, "parse_errors": p.ParseErrors, "qty_parse_errors": p.QtyParseErrors, "ignored": p.Ignored, diff --git a/internal/lotmatch/matcher.go b/internal/lotmatch/matcher.go new file mode 100644 index 0000000..2482ff9 --- /dev/null +++ b/internal/lotmatch/matcher.go @@ -0,0 +1,175 @@ +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) +} diff --git a/internal/lotmatch/resolver.go b/internal/lotmatch_old/resolver.go similarity index 100% rename from internal/lotmatch/resolver.go rename to internal/lotmatch_old/resolver.go diff --git a/internal/lotmatch/resolver_test.go b/internal/lotmatch_old/resolver_test.go similarity index 100% rename from internal/lotmatch/resolver_test.go rename to internal/lotmatch_old/resolver_test.go diff --git a/internal/services/stock_import.go b/internal/services/stock_import.go index 9396bdf..019bf7f 100644 --- a/internal/services/stock_import.go +++ b/internal/services/stock_import.go @@ -32,7 +32,7 @@ type StockImportProgress struct { Deleted int64 `json:"deleted,omitempty"` Unmapped int `json:"unmapped,omitempty"` Conflicts int `json:"conflicts,omitempty"` - FallbackMatches int `json:"fallback_matches,omitempty"` + AutoMapped int `json:"auto_mapped,omitempty"` ParseErrors int `json:"parse_errors,omitempty"` QtyParseErrors int `json:"qty_parse_errors,omitempty"` Ignored int `json:"ignored,omitempty"` @@ -49,7 +49,7 @@ type StockImportResult struct { Deleted int64 Unmapped int Conflicts int - FallbackMatches int + AutoMapped int ParseErrors int QtyParseErrors int Ignored int @@ -144,14 +144,15 @@ func (s *StockImportService) Import( } var ( - records []models.StockLog - unmapped int - conflicts int - fallbackMatches int - parseErrors int - qtyParseErrors int - ignored int - suggestionsByPN = make(map[string]StockMappingSuggestion) + records []models.StockLog + unmapped int + conflicts int + autoMapped int + parseErrors int + qtyParseErrors int + ignored int + suggestionsByPN = make(map[string]StockMappingSuggestion) + autoMappingsToAdd = make(map[string]models.LotPartnumber) // key -> mapping ) ignoreRules, err := s.loadIgnoreRules() if err != nil { @@ -174,19 +175,38 @@ func (s *StockImportService) Import( } partnumber := strings.TrimSpace(row.Article) key := normalizeKey(partnumber) + description := strings.TrimSpace(row.Description) + + // Check if already mapped mappedLots := partnumberMatcher.MatchLots(partnumber) if len(mappedLots) == 0 { - unmapped++ - suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ - Partnumber: partnumber, - Description: strings.TrimSpace(row.Description), - Reason: "unmapped", - }) + // Try to auto-map based on prefix match + if matchedLot := partnumberMatcher.FindPrefixMatch(partnumber); matchedLot != "" { + // Collect for batch insert later + descPtr := &description + if description == "" { + descPtr = nil + } + autoMappingsToAdd[key] = models.LotPartnumber{ + Partnumber: partnumber, + LotName: matchedLot, + Description: descPtr, + } + autoMapped++ + } else { + // No prefix match found + unmapped++ + suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ + Partnumber: partnumber, + Description: description, + Reason: "unmapped", + }) + } } else if len(mappedLots) > 1 { conflicts++ suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{ Partnumber: partnumber, - Description: strings.TrimSpace(row.Description), + Description: description, Reason: "conflict", }) } @@ -210,6 +230,45 @@ func (s *StockImportService) Import( }) } + // Batch create auto-mappings (only unique partnumbers) + if len(autoMappingsToAdd) > 0 { + // Get all existing partnumbers to check for duplicates + partnumbersToCheck := make([]string, 0, len(autoMappingsToAdd)) + for _, mapping := range autoMappingsToAdd { + partnumbersToCheck = append(partnumbersToCheck, mapping.Partnumber) + } + + var existingPartnumbers []string + if err := s.db.Model(&models.LotPartnumber{}). + Where("partnumber IN ?", partnumbersToCheck). + Pluck("partnumber", &existingPartnumbers).Error; err == nil { + + // Build set of existing partnumbers + existingSet := make(map[string]bool, len(existingPartnumbers)) + for _, pn := range existingPartnumbers { + existingSet[strings.ToLower(strings.TrimSpace(pn))] = true + } + + // Filter out duplicates + mappingsToInsert := make([]models.LotPartnumber, 0, len(autoMappingsToAdd)) + for _, mapping := range autoMappingsToAdd { + pnKey := strings.ToLower(strings.TrimSpace(mapping.Partnumber)) + if !existingSet[pnKey] { + mappingsToInsert = append(mappingsToInsert, mapping) + existingSet[pnKey] = true // Mark as added to prevent duplicates within batch + } + } + + // Insert only unique mappings + if len(mappingsToInsert) > 0 { + if err := s.db.CreateInBatches(mappingsToInsert, 100).Error; err != nil { + // Log error but don't fail the whole import + // These will show up as unmapped in suggestions + } + } + } + } + suggestions := collectSortedSuggestions(suggestionsByPN, 200) if len(records) == 0 { @@ -223,7 +282,7 @@ func (s *StockImportService) Import( ValidRows: len(records), Unmapped: unmapped, Conflicts: conflicts, - FallbackMatches: fallbackMatches, + AutoMapped: autoMapped, ParseErrors: parseErrors, QtyParseErrors: qtyParseErrors, Current: 40, @@ -289,7 +348,7 @@ func (s *StockImportService) Import( Deleted: deleted, Unmapped: unmapped, Conflicts: conflicts, - FallbackMatches: fallbackMatches, + AutoMapped: autoMapped, ParseErrors: parseErrors, QtyParseErrors: qtyParseErrors, Ignored: ignored, @@ -308,7 +367,7 @@ func (s *StockImportService) Import( Deleted: result.Deleted, Unmapped: result.Unmapped, Conflicts: result.Conflicts, - FallbackMatches: result.FallbackMatches, + AutoMapped: result.AutoMapped, ParseErrors: result.ParseErrors, QtyParseErrors: result.QtyParseErrors, Ignored: result.Ignored,