Add vendor mappings CSV import modal and fix vendor rename save

This commit is contained in:
Mikhail Chusavitin
2026-02-26 12:44:11 +03:00
parent 63454554c1
commit 6f1de7a20e
6 changed files with 318 additions and 8 deletions

View File

@@ -1,7 +1,9 @@
package handlers
import (
"bytes"
"context"
"encoding/csv"
"errors"
"fmt"
"io"
@@ -25,6 +27,7 @@ import (
)
const maxStockImportFileSize int64 = 25 * 1024 * 1024
const maxVendorMappingsCSVImportFileSize int64 = 10 * 1024 * 1024
// calculateMedian returns the median of a sorted slice of prices
func calculateMedian(prices []float64) float64 {
@@ -1283,6 +1286,7 @@ func (h *PricingHandler) UpsertVendorMapping(c *gin.Context) {
return
}
var req struct {
OriginalVendor string `json:"original_vendor"`
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber" binding:"required"`
LotName string `json:"lot_name"`
@@ -1303,7 +1307,7 @@ func (h *PricingHandler) UpsertVendorMapping(c *gin.Context) {
Qty: item.Qty,
})
}
if err := h.vendorMapService.UpsertMapping(req.Vendor, req.Partnumber, req.LotName, req.Description, items); err != nil {
if err := h.vendorMapService.UpsertMappingWithOriginalVendor(req.OriginalVendor, req.Vendor, req.Partnumber, req.LotName, req.Description, items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -1334,6 +1338,146 @@ func (h *PricingHandler) DeleteVendorMapping(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
func normalizeVendorMappingsCSVHeader(v string) string {
v = strings.TrimSpace(strings.TrimPrefix(v, "\uFEFF"))
v = strings.ToLower(v)
replacer := strings.NewReplacer(" ", "", "_", "", "-", "", ".", "")
return replacer.Replace(v)
}
func (h *PricingHandler) ImportVendorMappingsCSV(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
if fileHeader.Size > maxVendorMappingsCSVImportFileSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 10MB)"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, maxVendorMappingsCSVImportFileSize+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if int64(len(content)) > maxVendorMappingsCSVImportFileSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 10MB)"})
return
}
content = bytes.TrimPrefix(content, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM (Excel)
reader := csv.NewReader(bytes.NewReader(content))
reader.Comma = ';' // bible/patterns.md: Russian-locale Excel CSV
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("csv parse error: %v", err)})
return
}
if len(rows) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty csv"})
return
}
headerMap := map[string]int{}
for i, col := range rows[0] {
headerMap[normalizeVendorMappingsCSVHeader(col)] = i
}
findHeader := func(keys ...string) int {
for _, k := range keys {
if idx, ok := headerMap[normalizeVendorMappingsCSVHeader(k)]; ok {
return idx
}
}
return -1
}
vendorIdx := findHeader("vendor", "вендор", "поставщик")
partnumberIdx := findHeader("partnumber", "part_number", "pn", "партномер", "артикул")
lotIdx := findHeader("lot_name", "lot", "лот", аименование_lot")
descIdx := findHeader("description", "desc", "описание", "comment", "комментарий")
startRow := 1
if partnumberIdx < 0 || lotIdx < 0 {
// Fallback: no header row, assume fixed order vendor;partnumber;lot_name;description
vendorIdx, partnumberIdx, lotIdx, descIdx = 0, 1, 2, 3
startRow = 0
}
imported := 0
skipped := 0
errs := make([]string, 0, 10)
field := func(rec []string, idx int) string {
if idx < 0 || idx >= len(rec) {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(rec[idx], "\uFEFF"))
}
for i := startRow; i < len(rows); i++ {
rec := rows[i]
vendor := field(rec, vendorIdx)
partnumber := field(rec, partnumberIdx)
lotName := field(rec, lotIdx)
description := field(rec, descIdx)
// Skip empty lines.
if vendor == "" && partnumber == "" && lotName == "" && description == "" {
skipped++
continue
}
if partnumber == "" {
errs = append(errs, fmt.Sprintf("row %d: partnumber is required", i+1))
continue
}
if lotName == "" {
errs = append(errs, fmt.Sprintf("row %d: lot_name is required", i+1))
continue
}
if err := h.vendorMapService.UpsertMapping(vendor, partnumber, lotName, description, nil); err != nil {
errs = append(errs, fmt.Sprintf("row %d: %v", i+1, err))
continue
}
imported++
}
if imported == 0 && len(errs) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "import failed",
"errors": errs,
"imported": imported,
"skipped": skipped,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "import completed",
"imported": imported,
"skipped": skipped,
"errors": errs,
"hasErrors": len(errs) > 0,
})
}
func (h *PricingHandler) IgnoreVendorMapping(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{

View File

@@ -54,9 +54,14 @@ func normalizePartnumber(v string) string {
}
func (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, description string, bundleItems []models.LotBundleItem) error {
return s.UpsertMappingWithOriginalVendor(vendor, vendor, partnumber, lotName, description, bundleItems)
}
func (s *VendorMappingService) UpsertMappingWithOriginalVendor(originalVendor, vendor, partnumber, lotName, description string, bundleItems []models.LotBundleItem) error {
if s.db == nil {
return fmt.Errorf("offline mode: vendor mappings unavailable")
}
originalVendor = normalizeVendor(originalVendor)
vendor = normalizeVendor(vendor)
partnumber = normalizePartnumber(partnumber)
lotName = strings.TrimSpace(lotName)
@@ -78,6 +83,9 @@ func (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, descri
return s.db.Transaction(func(tx *gorm.DB) error {
var prev models.LotPartnumber
err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).First(&prev).Error
if errors.Is(err, gorm.ErrRecordNotFound) && originalVendor != "" && originalVendor != vendor {
err = tx.Where("vendor = ? AND partnumber = ?", originalVendor, partnumber).First(&prev).Error
}
if err == nil && description == "" && prev.Description != nil {
description = strings.TrimSpace(*prev.Description)
}
@@ -91,6 +99,11 @@ func (s *VendorMappingService) UpsertMapping(vendor, partnumber, lotName, descri
if err := tx.Where("vendor = ? AND partnumber = ?", vendor, partnumber).Delete(&models.LotPartnumber{}).Error; err != nil {
return err
}
if originalVendor != "" && originalVendor != vendor {
if err := tx.Where("vendor = ? AND partnumber = ?", originalVendor, partnumber).Delete(&models.LotPartnumber{}).Error; err != nil {
return err
}
}
if err := tx.Create(&models.LotPartnumber{
Vendor: vendor,
Partnumber: partnumber,