Go refactoring: - Split handlers/pricing.go (2446→291 lines) into 5 focused files - Split services/stock_import.go (1334→~400 lines) into stock_mappings.go + stock_parse.go - Split services/sync/service.go (1290→~250 lines) into 3 files JS extraction: - Move all inline <script> blocks to web/static/js/ (6 files) - Templates reduced: admin_pricing 2873→521, lot 1531→304, vendor_mappings 1063→169, etc. Competitor pricelists (migrations 033-039): - qt_competitors + partnumber_log_competitors tables - Excel import with column mapping, dedup, bulk insert - p/n→lot resolution via weighted_median, discount applied - Unmapped p/ns written to qt_vendor_partnumber_seen - Quote counts (unique/total) shown on /admin/competitors - price_method="weighted_median", price_period_days=0 stored explicitly Fix price_method/price_period_days for warehouse items: - warehouse: weighted_avg, period=0 - competitor: weighted_median, period=0 - Removes misleading DB defaults (was: median/90) Update bible: architecture.md, pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
11 KiB
Go
380 lines
11 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.mchus.pro/mchus/priceforge/internal/models"
|
||
"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")
|
||
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;ignore
|
||
vendorIdx, partnumberIdx, lotIdx, descIdx, ignoreIdx = 0, 1, 2, 3, 4
|
||
startRow = 0
|
||
}
|
||
|
||
imported := 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"))
|
||
}
|
||
|
||
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)
|
||
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
|
||
}
|
||
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
|
||
}
|
||
imported++
|
||
}
|
||
|
||
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", "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{
|
||
"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) 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"})
|
||
}
|