refactor lot matching into shared module

This commit is contained in:
2026-02-07 06:22:56 +03:00
parent b629af9742
commit 95b5f8bf65
14 changed files with 1190 additions and 520 deletions

View File

@@ -4,7 +4,6 @@ import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
@@ -14,8 +13,10 @@ import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
pricelistsvc "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -137,7 +138,7 @@ func (s *StockImportService) Import(
Total: 100,
})
partnumberMappings, err := s.loadPartnumberMappings()
partnumberMatcher, err := lotmatch.NewMappingMatcherFromDB(s.db)
if err != nil {
return nil, err
}
@@ -173,7 +174,7 @@ func (s *StockImportService) Import(
}
partnumber := strings.TrimSpace(row.Article)
key := normalizeKey(partnumber)
mappedLots := partnumberMappings[key]
mappedLots := partnumberMatcher.MatchLots(partnumber)
if len(mappedLots) == 0 {
unmapped++
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
@@ -342,74 +343,22 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
}
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
var logs []models.StockLog
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
return nil, err
}
resolver, err := s.newLotResolver()
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, l := range logs {
partnumber := strings.TrimSpace(l.Partnumber)
if partnumber == "" || l.Price <= 0 {
continue
}
lotName, _, err := resolver.resolve(partnumber)
if err != nil || strings.TrimSpace(lotName) == "" {
continue
}
weight := 0.0
if l.Qty != nil && *l.Qty > 0 {
weight = *l.Qty
}
grouped[lotName] = append(grouped[lotName], weightedPricePoint{
price: l.Price,
weight: weight,
})
}
items := make([]pricelistsvc.CreateItemInput, 0, len(grouped))
for lot, values := range grouped {
price := weightedMedian(values)
if price <= 0 {
continue
}
items := make([]pricelistsvc.CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
items = append(items, pricelistsvc.CreateItemInput{
LotName: lot,
Price: price,
PriceMethod: "weighted_median",
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].LotName < items[j].LotName
})
return items, nil
}
func (s *StockImportService) loadPartnumberMappings() (map[string][]string, error) {
var mappings []models.LotPartnumber
if err := s.db.Find(&mappings).Error; err != nil {
return nil, err
}
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, lots := range partnumberToLots {
partnumberToLots[key] = uniqueStrings(lots)
}
return partnumberToLots, nil
}
func upsertSuggestion(prev StockMappingSuggestion, candidate StockMappingSuggestion) StockMappingSuggestion {
if strings.TrimSpace(prev.Partnumber) == "" {
return candidate
@@ -674,11 +623,9 @@ func normalizeIgnoreMatchType(v string) string {
}
var (
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
errResolveConflict = errors.New("multiple lot matches")
errResolveNotFound = errors.New("lot not found")
reISODate = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
reRuDate = regexp.MustCompile(`\b([0-3]\d)\.([01]\d)\.(20\d{2})\b`)
mxlCellRe = regexp.MustCompile(`\{16,\d+,\s*\{1,1,\s*\{"ru","(.*?)"\}\s*\},0\},(\d+),`)
)
func parseStockRows(filename string, content []byte) ([]stockImportRow, error) {
@@ -1020,124 +967,8 @@ func weightedMedian(values []weightedPricePoint) float64 {
return items[len(items)-1].price
}
type lotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
func (s *StockImportService) newLotResolver() (*lotResolver, error) {
var mappings []models.LotPartnumber
if err := s.db.Find(&mappings).Error; err != nil {
return nil, err
}
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
p := normalizeKey(m.Partnumber)
if p == "" || strings.TrimSpace(m.LotName) == "" {
continue
}
partnumberToLots[p] = append(partnumberToLots[p], m.LotName)
}
var lots []models.Lot
if err := s.db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, err
}
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
}
k := normalizeKey(name)
exactLots[k] = 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,
}, nil
}
func (r *lotResolver) resolve(article string) (string, string, error) {
key := normalizeKey(article)
if key == "" {
return "", "", errResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
uniq := uniqueStrings(mapped)
if len(uniq) == 1 {
return uniq[0], "mapping_table", nil
}
return "", "", errResolveConflict
}
if lot, ok := r.exactLots[key]; ok {
return lot, "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 normalizeKey(v string) string {
return strings.ToLower(strings.TrimSpace(v))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
k := strings.ToLower(v)
if seen[k] {
continue
}
seen[k] = true
out = append(out, v)
}
sort.Strings(out)
return out
return lotmatch.NormalizeKey(v)
}
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {