refactor lot matching into shared module
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user