diff --git a/bible/api.md b/bible/api.md index 32322af..2b1ef6d 100644 --- a/bible/api.md +++ b/bible/api.md @@ -62,7 +62,8 @@ | 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) | +| POST | `/api/admin/pricing/vendor-mappings/import-csv` | Import vendor mappings from CSV (`;`, UTF-8/BOM, columns: vendor;partnumber;lot_name;description;ignore; if ignore is set and lot_name empty → mark ignored) | +| GET | `/api/admin/pricing/vendor-mappings/export-unmapped-csv` | Export CSV template of unmapped vendor partnumbers for user filling (`vendor;partnumber;lot_name;description;ignore`) | | 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 672e355..c119b0b 100644 --- a/bible/vendor-mapping.md +++ b/bible/vendor-mapping.md @@ -114,7 +114,8 @@ qt_lot_bundle_items: bundle_lot_name, lot_name, qty - Формат CSV для Excel (RU locale): разделитель `;` - Кодировка: `UTF-8` (BOM допускается и поддерживается) -- Рекомендуемые колонки: `vendor;partnumber;lot_name;description` +- Рекомендуемые колонки: `vendor;partnumber;lot_name;description;ignore` - Допустим импорт как с заголовком, так и без заголовка (в фиксированном порядке колонок выше) - Пустые строки пропускаются -- Для строки обязательны `partnumber` и `lot_name` +- Для строки обязательны `partnumber` и (`lot_name` или заполненный `ignore`) +- Если `ignore` заполнен и `lot_name` пустой, строка помечается как ignored в `qt_vendor_partnumber_seen` diff --git a/cmd/pfs/main.go b/cmd/pfs/main.go index 0ce83dd..8513937 100644 --- a/cmd/pfs/main.go +++ b/cmd/pfs/main.go @@ -639,6 +639,7 @@ func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionMa pricingAdmin.GET("/vendor-mappings/detail", pricingHandler.GetVendorMappingDetail) pricingAdmin.POST("/vendor-mappings", pricingHandler.UpsertVendorMapping) pricingAdmin.POST("/vendor-mappings/import-csv", pricingHandler.ImportVendorMappingsCSV) + pricingAdmin.GET("/vendor-mappings/export-unmapped-csv", pricingHandler.ExportUnmappedVendorMappingsCSV) pricingAdmin.DELETE("/vendor-mappings", pricingHandler.DeleteVendorMapping) pricingAdmin.POST("/vendor-mappings/ignore", pricingHandler.IgnoreVendorMapping) pricingAdmin.POST("/vendor-mappings/unignore", pricingHandler.UnignoreVendorMapping) diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 521b5f9..4a1662a 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -1413,17 +1413,19 @@ func (h *PricingHandler) ImportVendorMappingsCSV(c *gin.Context) { vendorIdx := findHeader("vendor", "вендор", "поставщик") partnumberIdx := findHeader("partnumber", "part_number", "pn", "партномер", "артикул") lotIdx := findHeader("lot_name", "lot", "лот", "наименование_lot") - descIdx := findHeader("description", "desc", "описание", "comment", "комментарий") + descIdx := findHeader("description", "desc", "описание", "comment", "комментарий") + ignoreIdx := findHeader("ignore", "ignored", "игнор", "ignore_flag") 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 - } + // Fallback: no header row, assume fixed order vendor;partnumber;lot_name;description;ignore + vendorIdx, partnumberIdx, lotIdx, descIdx, ignoreIdx = 0, 1, 2, 3, 4 + startRow = 0 + } - imported := 0 - skipped := 0 + imported := 0 + ignoredCnt := 0 + skipped := 0 errs := make([]string, 0, 10) field := func(rec []string, idx int) string { @@ -1437,23 +1439,32 @@ func (h *PricingHandler) ImportVendorMappingsCSV(c *gin.Context) { rec := rows[i] vendor := field(rec, vendorIdx) partnumber := field(rec, partnumberIdx) - lotName := field(rec, lotIdx) - description := field(rec, descIdx) + lotName := field(rec, lotIdx) + description := field(rec, descIdx) + ignoreRaw := field(rec, ignoreIdx) // Skip empty lines. - if vendor == "" && partnumber == "" && lotName == "" && description == "" { - skipped++ - continue - } - if partnumber == "" { + if vendor == "" && partnumber == "" && lotName == "" && description == "" && ignoreRaw == "" { + 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 { + if lotName == "" { + if strings.TrimSpace(ignoreRaw) != "" { + if err := h.vendorMapService.SetIgnore(vendor, partnumber, h.dbUsername, true); err != nil { + errs = append(errs, fmt.Sprintf("row %d: ignore failed: %v", i+1, err)) + continue + } + ignoredCnt++ + continue + } + errs = append(errs, fmt.Sprintf("row %d: lot_name is required (or set Ignore)", 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 } @@ -1465,6 +1476,7 @@ func (h *PricingHandler) ImportVendorMappingsCSV(c *gin.Context) { "error": "import failed", "errors": errs, "imported": imported, + "ignored": ignoredCnt, "skipped": skipped, }) return @@ -1472,12 +1484,67 @@ func (h *PricingHandler) ImportVendorMappingsCSV(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "import completed", "imported": imported, + "ignored": ignoredCnt, "skipped": skipped, "errors": errs, "hasErrors": len(errs) > 0, }) } +func (h *PricingHandler) ExportUnmappedVendorMappingsCSV(c *gin.Context) { + if h.vendorMapService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Глобальные сопоставления доступны только в онлайн режиме", + "offline": true, + }) + return + } + + filename := fmt.Sprintf("vendor_mappings_unmapped_%s.csv", time.Now().Format("20060102_150405")) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + c.Status(http.StatusOK) + _, _ = c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) + + w := csv.NewWriter(c.Writer) + w.Comma = ';' + if err := w.Write([]string{"vendor", "partnumber", "lot_name", "description", "ignore"}); err != nil { + c.String(http.StatusInternalServerError, "export failed: %v", err) + return + } + + page := 1 + perPage := 500 + for { + items, total, err := h.vendorMapService.List(page, perPage, "", true, false) + if err != nil { + c.String(http.StatusInternalServerError, "export failed: %v", err) + return + } + for _, it := range items { + if err := w.Write([]string{ + strings.TrimSpace(it.Vendor), + strings.TrimSpace(it.Partnumber), + "", // to be filled by user + strings.TrimSpace(it.Description), + "", // optional ignore marker + }); err != nil { + c.String(http.StatusInternalServerError, "export failed: %v", err) + return + } + } + w.Flush() + if err := w.Error(); err != nil { + c.String(http.StatusInternalServerError, "export failed: %v", err) + return + } + if int64(page*perPage) >= total || len(items) == 0 { + break + } + page++ + } +} + func (h *PricingHandler) IgnoreVendorMapping(c *gin.Context) { if h.vendorMapService == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ diff --git a/web/templates/vendor_mappings.html b/web/templates/vendor_mappings.html index 18dc14f..dfc05e1 100644 --- a/web/templates/vendor_mappings.html +++ b/web/templates/vendor_mappings.html @@ -58,7 +58,7 @@ Игнорируемые - + @@ -89,17 +89,19 @@