Files
PriceForge/internal/handlers/pricing_vendor.go
Mikhail Chusavitin f73e3d144d Vendor mapping: wildcard ignore patterns, bulk CSV import, multi-lot qty
- Add glob pattern support (* and ?) for ignore rules stored in
  qt_vendor_partnumber_seen (is_pattern flag, migration 041)
- Pattern matching applied in stock/competitor import, partnumber book
  snapshot, and vendor mappings list (Go-side via NormalizeKey)
- BulkUpsertMappings: replace N+1 loop with two batch SQL upserts,
  validating all lots in a single query (~1500 queries → 3-4)
- CSV import: multi-lot per PN via repeated rows, optional qty column
- CSV export: updated column format vendor;partnumber;lot_name;qty;description;ignore;notes
- UI: ignore patterns section with add/delete, import progress feedback
- Update bible-local/vendor-mapping.md with new CSV format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:41:48 +03:00

491 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/priceforge/internal/models"
"git.mchus.pro/mchus/priceforge/internal/services"
"github.com/gin-gonic/gin"
)
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) ListVendorMappings(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
unmappedOnly := c.DefaultQuery("unmapped_only", "false") == "true"
ignoredOnly := c.DefaultQuery("ignored_only", "false") == "true"
items, total, err := h.vendorMapService.List(page, perPage, search, unmappedOnly, ignoredOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *PricingHandler) GetVendorMappingDetail(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
vendor := strings.TrimSpace(c.Query("vendor"))
partnumber := strings.TrimSpace(c.Query("partnumber"))
if partnumber == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "partnumber is required"})
return
}
detail, err := h.vendorMapService.GetDetail(vendor, partnumber)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, detail)
}
func (h *PricingHandler) UpsertVendorMapping(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
OriginalVendor string `json:"original_vendor"`
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber" binding:"required"`
LotName string `json:"lot_name"`
Description string `json:"description"`
Items []struct {
LotName string `json:"lot_name"`
Qty float64 `json:"qty"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
items := make([]models.PartnumberBookLot, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, models.PartnumberBookLot{
LotName: item.LotName,
Qty: item.Qty,
})
}
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
}
c.JSON(http.StatusOK, gin.H{"message": "mapping saved"})
}
func (h *PricingHandler) DeleteVendorMapping(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
deleted, err := h.vendorMapService.DeleteMapping(req.Vendor, req.Partnumber)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
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")
qtyIdx := findHeader("qty", "quantity", "кол-во", "количество")
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;qty;description;ignore
vendorIdx, partnumberIdx, lotIdx, qtyIdx, descIdx, ignoreIdx = 0, 1, 2, 3, 4, 5
startRow = 0
}
ignoredCnt := 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"))
}
// pnEntry accumulates lots for a single partnumber across multiple CSV rows.
type pnEntry struct {
vendor string
description string
ignoreRaw string
lots []models.PartnumberBookLot
}
// pnOrder preserves the order in which partnumbers first appear.
pnOrder := make([]string, 0)
pnMap := make(map[string]*pnEntry)
for i := startRow; i < len(rows); i++ {
rec := rows[i]
vendor := field(rec, vendorIdx)
partnumber := field(rec, partnumberIdx)
lotName := field(rec, lotIdx)
qtyRaw := field(rec, qtyIdx)
description := field(rec, descIdx)
ignoreRaw := field(rec, ignoreIdx)
// Skip empty lines.
if vendor == "" && partnumber == "" && lotName == "" && description == "" && ignoreRaw == "" {
skipped++
continue
}
if partnumber == "" {
errs = append(errs, fmt.Sprintf("row %d: partnumber is required", i+1))
continue
}
entry, exists := pnMap[partnumber]
if !exists {
entry = &pnEntry{vendor: vendor, description: description, ignoreRaw: ignoreRaw}
pnMap[partnumber] = entry
pnOrder = append(pnOrder, partnumber)
}
// First non-empty description wins; vendor and ignoreRaw also from first row.
if entry.description == "" && description != "" {
entry.description = description
}
if lotName != "" {
qty := 1.0
if qtyRaw != "" {
if q, err := strconv.ParseFloat(strings.ReplaceAll(qtyRaw, ",", "."), 64); err == nil && q > 0 {
qty = q
}
}
entry.lots = append(entry.lots, models.PartnumberBookLot{LotName: lotName, Qty: qty})
}
}
// Split entries into mappings (bulk) and ignores (loop, usually few).
bulkEntries := make([]services.BulkMappingEntry, 0, len(pnOrder))
for _, partnumber := range pnOrder {
entry := pnMap[partnumber]
if len(entry.lots) == 0 {
if strings.TrimSpace(entry.ignoreRaw) != "" {
if err := h.vendorMapService.SetIgnore(entry.vendor, partnumber, h.dbUsername, true); err != nil {
errs = append(errs, fmt.Sprintf("pn %q: ignore failed: %v", partnumber, err))
continue
}
ignoredCnt++
continue
}
errs = append(errs, fmt.Sprintf("pn %q: lot_name is required (or set Ignore)", partnumber))
continue
}
bulkEntries = append(bulkEntries, services.BulkMappingEntry{
Vendor: entry.vendor,
Partnumber: partnumber,
Description: entry.description,
Lots: entry.lots,
})
}
imported, bulkErrs, bulkFatal := h.vendorMapService.BulkUpsertMappings(bulkEntries)
errs = append(errs, bulkErrs...)
if bulkFatal != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": bulkFatal.Error(),
"imported": imported,
"ignored": ignoredCnt,
"skipped": skipped,
})
return
}
if imported == 0 && len(errs) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "import failed",
"errors": errs,
"imported": imported,
"ignored": ignoredCnt,
"skipped": skipped,
})
return
}
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", "qty", "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),
"", // lot_name — to be filled by user
"", // qty — to be filled by user (empty = 1)
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{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.vendorMapService.SetIgnore(req.Vendor, req.Partnumber, h.dbUsername, true); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
}
func (h *PricingHandler) ListIgnorePatterns(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Глобальные сопоставления доступны только в онлайн режиме", "offline": true})
return
}
patterns, err := h.vendorMapService.ListIgnorePatterns()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"patterns": patterns})
}
func (h *PricingHandler) CreateIgnorePattern(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Глобальные сопоставления доступны только в онлайн режиме", "offline": true})
return
}
var req struct {
Pattern string `json:"pattern" binding:"required"`
Vendor string `json:"vendor"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pattern := strings.TrimSpace(req.Pattern)
if !strings.ContainsAny(pattern, "*?") {
c.JSON(http.StatusBadRequest, gin.H{"error": "pattern must contain * or ?"})
return
}
if err := h.vendorMapService.SetIgnore(req.Vendor, pattern, h.dbUsername, true); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pattern created"})
}
func (h *PricingHandler) DeleteIgnorePattern(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Глобальные сопоставления доступны только в онлайн режиме", "offline": true})
return
}
var req struct {
ID uint64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.vendorMapService.DeleteIgnorePattern(req.ID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pattern deleted"})
}
func (h *PricingHandler) UnignoreVendorMapping(c *gin.Context) {
if h.vendorMapService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Глобальные сопоставления доступны только в онлайн режиме",
"offline": true,
})
return
}
var req struct {
Vendor string `json:"vendor"`
Partnumber string `json:"partnumber" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.vendorMapService.SetIgnore(req.Vendor, req.Partnumber, h.dbUsername, false); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "unignored"})
}