Implement warehouse/lot pricing updates and configurator performance fixes

This commit is contained in:
2026-02-07 05:20:35 +03:00
parent c1a31e5ee0
commit 7c741ff675
26 changed files with 1701 additions and 305 deletions

View File

@@ -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