diff --git a/bible/api.md b/bible/api.md index 53ece8f..32322af 100644 --- a/bible/api.md +++ b/bible/api.md @@ -62,6 +62,7 @@ | GET | `/api/admin/pricing/lots` | LOT list | | GET/POST | `/api/admin/pricing/stock-mappings` | Stock partnumber mappings | | GET/POST/DELETE | `/api/admin/pricing/vendor-mappings` | Vendor partnumber mappings (DELETE removes mapping and seen-row from global list) | +| POST | `/api/admin/pricing/vendor-mappings/import-csv` | Import vendor mappings from CSV (`;`, UTF-8/BOM, columns: vendor;partnumber;lot_name;description) | | GET | `/api/admin/pricing/alerts` | Alerts list | | GET | `/api/admin/pricing/partnumber-books` | List all partnumber book snapshots with item counts | | POST | `/api/admin/pricing/partnumber-books` | Create partnumber book snapshot (returns task_id) | diff --git a/bible/vendor-mapping.md b/bible/vendor-mapping.md index e0b7af9..672e355 100644 --- a/bible/vendor-mapping.md +++ b/bible/vendor-mapping.md @@ -107,3 +107,14 @@ qt_lot_bundle_items: bundle_lot_name, lot_name, qty | Stock import seen/ignore | `internal/services/stock_import.go` | | Модели | `internal/models/lot.go`, `internal/models/configuration.go` | | Роутинг | `cmd/pfs/main.go` | + +--- + +## CSV Import (Global Vendor Mappings UI) + +- Формат CSV для Excel (RU locale): разделитель `;` +- Кодировка: `UTF-8` (BOM допускается и поддерживается) +- Рекомендуемые колонки: `vendor;partnumber;lot_name;description` +- Допустим импорт как с заголовком, так и без заголовка (в фиксированном порядке колонок выше) +- Пустые строки пропускаются +- Для строки обязательны `partnumber` и `lot_name` diff --git a/cmd/pfs/main.go b/cmd/pfs/main.go index 6993bc4..0ce83dd 100644 --- a/cmd/pfs/main.go +++ b/cmd/pfs/main.go @@ -636,11 +636,12 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) pricingAdmin.GET("/vendor-mappings", pricingHandler.ListVendorMappings) - pricingAdmin.GET("/vendor-mappings/detail", pricingHandler.GetVendorMappingDetail) - pricingAdmin.POST("/vendor-mappings", pricingHandler.UpsertVendorMapping) - pricingAdmin.DELETE("/vendor-mappings", pricingHandler.DeleteVendorMapping) - pricingAdmin.POST("/vendor-mappings/ignore", pricingHandler.IgnoreVendorMapping) - pricingAdmin.POST("/vendor-mappings/unignore", pricingHandler.UnignoreVendorMapping) + pricingAdmin.GET("/vendor-mappings/detail", pricingHandler.GetVendorMappingDetail) + pricingAdmin.POST("/vendor-mappings", pricingHandler.UpsertVendorMapping) + pricingAdmin.POST("/vendor-mappings/import-csv", pricingHandler.ImportVendorMappingsCSV) + pricingAdmin.DELETE("/vendor-mappings", pricingHandler.DeleteVendorMapping) + pricingAdmin.POST("/vendor-mappings/ignore", pricingHandler.IgnoreVendorMapping) + pricingAdmin.POST("/vendor-mappings/unignore", pricingHandler.UnignoreVendorMapping) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index bde4164..521b5f9 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -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{ diff --git a/internal/services/vendor_mapping.go b/internal/services/vendor_mapping.go index 2135d17..f164b23 100644 --- a/internal/services/vendor_mapping.go +++ b/internal/services/vendor_mapping.go @@ -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, diff --git a/web/templates/vendor_mappings.html b/web/templates/vendor_mappings.html index fb57ff3..18dc14f 100644 --- a/web/templates/vendor_mappings.html +++ b/web/templates/vendor_mappings.html @@ -58,7 +58,9 @@ Игнорируемые + +