Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -33,6 +33,7 @@ type StockImportProgress struct {
|
||||
Conflicts int `json:"conflicts,omitempty"`
|
||||
FallbackMatches int `json:"fallback_matches,omitempty"`
|
||||
ParseErrors int `json:"parse_errors,omitempty"`
|
||||
QtyParseErrors int `json:"qty_parse_errors,omitempty"`
|
||||
Ignored int `json:"ignored,omitempty"`
|
||||
MappingSuggestions []StockMappingSuggestion `json:"mapping_suggestions,omitempty"`
|
||||
ImportDate string `json:"import_date,omitempty"`
|
||||
@@ -49,6 +50,7 @@ type StockImportResult struct {
|
||||
Conflicts int
|
||||
FallbackMatches int
|
||||
ParseErrors int
|
||||
QtyParseErrors int
|
||||
Ignored int
|
||||
MappingSuggestions []StockMappingSuggestion
|
||||
ImportDate time.Time
|
||||
@@ -87,6 +89,13 @@ type stockImportRow struct {
|
||||
Vendor string
|
||||
Price float64
|
||||
Qty float64
|
||||
QtyRaw string
|
||||
QtyInvalid bool
|
||||
}
|
||||
|
||||
type weightedPricePoint struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
|
||||
func (s *StockImportService) Import(
|
||||
@@ -128,7 +137,7 @@ func (s *StockImportService) Import(
|
||||
Total: 100,
|
||||
})
|
||||
|
||||
resolver, err := s.newLotResolver()
|
||||
partnumberMappings, err := s.loadPartnumberMappings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,6 +148,7 @@ func (s *StockImportService) Import(
|
||||
conflicts int
|
||||
fallbackMatches int
|
||||
parseErrors int
|
||||
qtyParseErrors int
|
||||
ignored int
|
||||
suggestionsByPN = make(map[string]StockMappingSuggestion)
|
||||
)
|
||||
@@ -152,41 +162,32 @@ func (s *StockImportService) Import(
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if row.QtyInvalid {
|
||||
qtyParseErrors++
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if shouldIgnoreStockRow(row, ignoreRules) {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
lot, matchType, resolveErr := resolver.resolve(row.Article)
|
||||
if resolveErr != nil {
|
||||
trimmedPN := strings.TrimSpace(row.Article)
|
||||
if trimmedPN != "" {
|
||||
key := normalizeKey(trimmedPN)
|
||||
if key != "" {
|
||||
reason := "unmapped"
|
||||
if errors.Is(resolveErr, errResolveConflict) {
|
||||
reason = "conflict"
|
||||
}
|
||||
candidate := StockMappingSuggestion{
|
||||
Partnumber: trimmedPN,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: reason,
|
||||
}
|
||||
if prev, ok := suggestionsByPN[key]; !ok ||
|
||||
(strings.TrimSpace(prev.Description) == "" && candidate.Description != "") ||
|
||||
(prev.Reason != "conflict" && candidate.Reason == "conflict") {
|
||||
suggestionsByPN[key] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.Is(resolveErr, errResolveConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
unmapped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if matchType == "article_exact" || matchType == "prefix" {
|
||||
fallbackMatches++
|
||||
partnumber := strings.TrimSpace(row.Article)
|
||||
key := normalizeKey(partnumber)
|
||||
mappedLots := partnumberMappings[key]
|
||||
if len(mappedLots) == 0 {
|
||||
unmapped++
|
||||
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||
Partnumber: partnumber,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: "unmapped",
|
||||
})
|
||||
} else if len(mappedLots) > 1 {
|
||||
conflicts++
|
||||
suggestionsByPN[key] = upsertSuggestion(suggestionsByPN[key], StockMappingSuggestion{
|
||||
Partnumber: partnumber,
|
||||
Description: strings.TrimSpace(row.Description),
|
||||
Reason: "conflict",
|
||||
})
|
||||
}
|
||||
|
||||
var comments *string
|
||||
@@ -199,30 +200,31 @@ func (s *StockImportService) Import(
|
||||
}
|
||||
qty := row.Qty
|
||||
records = append(records, models.StockLog{
|
||||
Lot: lot,
|
||||
Date: importDate,
|
||||
Price: row.Price,
|
||||
Comments: comments,
|
||||
Vendor: vendor,
|
||||
Qty: &qty,
|
||||
Partnumber: partnumber,
|
||||
Date: importDate,
|
||||
Price: row.Price,
|
||||
Comments: comments,
|
||||
Vendor: vendor,
|
||||
Qty: &qty,
|
||||
})
|
||||
}
|
||||
|
||||
suggestions := collectSortedSuggestions(suggestionsByPN, 200)
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("no valid rows after mapping")
|
||||
return nil, fmt.Errorf("no valid rows after filtering")
|
||||
}
|
||||
|
||||
report(StockImportProgress{
|
||||
Status: "mapping",
|
||||
Message: "Сопоставление article -> lot завершено",
|
||||
Message: "Валидация строк завершена",
|
||||
RowsTotal: len(rows),
|
||||
ValidRows: len(records),
|
||||
Unmapped: unmapped,
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
QtyParseErrors: qtyParseErrors,
|
||||
Current: 40,
|
||||
Total: 100,
|
||||
})
|
||||
@@ -261,10 +263,14 @@ func (s *StockImportService) Import(
|
||||
return nil, fmt.Errorf("pricelist service unavailable")
|
||||
}
|
||||
pl, err := s.pricelistSvc.CreateForSourceWithProgress(createdBy, string(models.PricelistSourceWarehouse), items, func(p pricelistsvc.CreateProgress) {
|
||||
current := 70 + int(float64(p.Current)*0.3)
|
||||
if p.Status != "completed" && current >= 100 {
|
||||
current = 99
|
||||
}
|
||||
report(StockImportProgress{
|
||||
Status: "recalculating_warehouse",
|
||||
Message: p.Message,
|
||||
Current: 70 + int(float64(p.Current)*0.3),
|
||||
Current: current,
|
||||
Total: 100,
|
||||
})
|
||||
})
|
||||
@@ -283,6 +289,7 @@ func (s *StockImportService) Import(
|
||||
Conflicts: conflicts,
|
||||
FallbackMatches: fallbackMatches,
|
||||
ParseErrors: parseErrors,
|
||||
QtyParseErrors: qtyParseErrors,
|
||||
Ignored: ignored,
|
||||
MappingSuggestions: suggestions,
|
||||
ImportDate: importDate,
|
||||
@@ -301,6 +308,7 @@ func (s *StockImportService) Import(
|
||||
Conflicts: result.Conflicts,
|
||||
FallbackMatches: result.FallbackMatches,
|
||||
ParseErrors: result.ParseErrors,
|
||||
QtyParseErrors: result.QtyParseErrors,
|
||||
Ignored: result.Ignored,
|
||||
MappingSuggestions: result.MappingSuggestions,
|
||||
ImportDate: result.ImportDate.Format("2006-01-02"),
|
||||
@@ -335,28 +343,45 @@ func (s *StockImportService) replaceStockLogs(records []models.StockLog) (int64,
|
||||
|
||||
func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.CreateItemInput, error) {
|
||||
var logs []models.StockLog
|
||||
if err := s.db.Select("lot, price").Where("price > 0").Find(&logs).Error; err != nil {
|
||||
if err := s.db.Select("partnumber, price, qty").Where("price > 0").Find(&logs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]float64)
|
||||
resolver, err := s.newLotResolver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := make(map[string][]weightedPricePoint)
|
||||
for _, l := range logs {
|
||||
lot := strings.TrimSpace(l.Lot)
|
||||
if lot == "" || l.Price <= 0 {
|
||||
partnumber := strings.TrimSpace(l.Partnumber)
|
||||
if partnumber == "" || l.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
grouped[lot] = append(grouped[lot], l.Price)
|
||||
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, prices := range grouped {
|
||||
price := median(prices)
|
||||
for lot, values := range grouped {
|
||||
price := weightedMedian(values)
|
||||
if price <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, pricelistsvc.CreateItemInput{
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
PriceMethod: "weighted_median",
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
@@ -365,6 +390,39 @@ func (s *StockImportService) buildWarehousePricelistItems() ([]pricelistsvc.Crea
|
||||
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
|
||||
}
|
||||
if strings.TrimSpace(prev.Description) == "" && strings.TrimSpace(candidate.Description) != "" {
|
||||
prev.Description = candidate.Description
|
||||
}
|
||||
if prev.Reason != "conflict" && candidate.Reason == "conflict" {
|
||||
prev.Reason = "conflict"
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
func (s *StockImportService) ListMappings(page, perPage int, search string) ([]models.LotPartnumber, int64, error) {
|
||||
if s.db == nil {
|
||||
return nil, 0, fmt.Errorf("offline mode: mappings unavailable")
|
||||
@@ -669,7 +727,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
qty, err := parseLocalizedFloat(r[6])
|
||||
qtyRaw := strings.TrimSpace(r[6])
|
||||
qty, err := parseLocalizedQty(qtyRaw)
|
||||
if err != nil {
|
||||
qty = 0
|
||||
}
|
||||
@@ -680,6 +739,8 @@ func parseMXLRows(content []byte) ([]stockImportRow, error) {
|
||||
Vendor: strings.TrimSpace(r[4]),
|
||||
Price: price,
|
||||
Qty: qty,
|
||||
QtyRaw: qtyRaw,
|
||||
QtyInvalid: err != nil,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -767,6 +828,9 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
idxVendor, hasVendor := headers["вендор"]
|
||||
idxPrice := headers["стоимость"]
|
||||
idxQty, hasQty := headers["свободно"]
|
||||
if !hasQty {
|
||||
return nil, fmt.Errorf("xlsx parsing failed: qty column 'Свободно' not found")
|
||||
}
|
||||
for i := headerRow + 1; i < len(grid); i++ {
|
||||
row := grid[i]
|
||||
article := strings.TrimSpace(row[idxArticle])
|
||||
@@ -778,10 +842,14 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
continue
|
||||
}
|
||||
qty := 0.0
|
||||
qtyRaw := ""
|
||||
qtyInvalid := false
|
||||
if hasQty {
|
||||
qty, err = parseLocalizedFloat(row[idxQty])
|
||||
qtyRaw = strings.TrimSpace(row[idxQty])
|
||||
qty, err = parseLocalizedQty(qtyRaw)
|
||||
if err != nil {
|
||||
qty = 0
|
||||
qtyInvalid = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +873,8 @@ func parseXLSXRows(content []byte) ([]stockImportRow, error) {
|
||||
Vendor: vendor,
|
||||
Price: price,
|
||||
Qty: qty,
|
||||
QtyRaw: qtyRaw,
|
||||
QtyInvalid: qtyInvalid,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
@@ -821,6 +891,23 @@ func parseLocalizedFloat(value string) (float64, error) {
|
||||
return strconv.ParseFloat(clean, 64)
|
||||
}
|
||||
|
||||
func parseLocalizedQty(value string) (float64, error) {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
return 0, fmt.Errorf("empty qty")
|
||||
}
|
||||
if v, err := parseLocalizedFloat(clean); err == nil {
|
||||
return v, nil
|
||||
}
|
||||
// Tolerate strings like "1 200 шт" by extracting the first numeric token.
|
||||
re := regexp.MustCompile(`[-+]?\d[\d\s\u00a0]*(?:[.,]\d+)?`)
|
||||
match := re.FindString(clean)
|
||||
if strings.TrimSpace(match) == "" {
|
||||
return 0, fmt.Errorf("invalid qty: %s", value)
|
||||
}
|
||||
return parseLocalizedFloat(match)
|
||||
}
|
||||
|
||||
func detectImportDate(content []byte, filename string, fileModTime time.Time) time.Time {
|
||||
if d, ok := extractDateFromText(string(content)); ok {
|
||||
return d
|
||||
@@ -885,6 +972,54 @@ func median(values []float64) float64 {
|
||||
return c[n/2]
|
||||
}
|
||||
|
||||
func weightedMedian(values []weightedPricePoint) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type pair struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
items := make([]pair, 0, len(values))
|
||||
totalWeight := 0.0
|
||||
prices := make([]float64, 0, len(values))
|
||||
|
||||
for _, v := range values {
|
||||
if v.price <= 0 {
|
||||
continue
|
||||
}
|
||||
prices = append(prices, v.price)
|
||||
w := v.weight
|
||||
if w > 0 {
|
||||
items = append(items, pair{price: v.price, weight: w})
|
||||
totalWeight += w
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for rows without positive weights.
|
||||
if totalWeight <= 0 {
|
||||
return median(prices)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].price == items[j].price {
|
||||
return items[i].weight < items[j].weight
|
||||
}
|
||||
return items[i].price < items[j].price
|
||||
})
|
||||
|
||||
threshold := totalWeight / 2.0
|
||||
acc := 0.0
|
||||
for _, it := range items {
|
||||
acc += it.weight
|
||||
if acc >= threshold {
|
||||
return it.price
|
||||
}
|
||||
}
|
||||
return items[len(items)-1].price
|
||||
}
|
||||
|
||||
type lotResolver struct {
|
||||
partnumberToLots map[string][]string
|
||||
exactLots map[string]string
|
||||
|
||||
Reference in New Issue
Block a user