- 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>
491 lines
15 KiB
Go
491 lines
15 KiB
Go
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"})
|
||
}
|