Add vendor mappings CSV import modal and fix vendor rename save
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user