Files
PriceForge/internal/handlers/pricing_vendor.go
Mikhail Chusavitin f48615e8a9 Modularize Go files, extract JS to static, implement competitor pricelists
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>
2026-03-13 07:44:10 +03:00

380 lines
11 KiB
Go
Raw 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"
"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"})
}