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>
246 lines
7.7 KiB
Go
246 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/services"
|
|
"git.mchus.pro/mchus/priceforge/internal/tasks"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func (h *PricingHandler) ImportStockLog(c *gin.Context) {
|
|
if h.stockImportService == 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 > maxStockImportFileSize {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large (max 25MB)"})
|
|
return
|
|
}
|
|
|
|
// Read create_pricelist parameter
|
|
createPricelistStr := c.PostForm("create_pricelist")
|
|
createPricelist := createPricelistStr == "true"
|
|
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(io.LimitReader(file, maxStockImportFileSize+1))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
|
|
return
|
|
}
|
|
if int64(len(content)) > maxStockImportFileSize {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large (max 25MB)"})
|
|
return
|
|
}
|
|
modTime := time.Now()
|
|
if statter, ok := file.(interface{ Stat() (os.FileInfo, error) }); ok {
|
|
if st, statErr := statter.Stat(); statErr == nil {
|
|
modTime = st.ModTime()
|
|
}
|
|
}
|
|
|
|
filename := fileHeader.Filename
|
|
|
|
taskID := h.taskManager.Submit(tasks.TaskTypeStockImport, func(ctx context.Context, progressCb func(int, string)) (map[string]interface{}, error) {
|
|
result, impErr := h.stockImportService.Import(filename, content, modTime, h.dbUsername, createPricelist, func(p services.StockImportProgress) {
|
|
// Convert service progress to task progress
|
|
var progress int
|
|
if p.Total > 0 {
|
|
progress = int(float64(p.Current) / float64(p.Total) * 100)
|
|
}
|
|
progressCb(progress, p.Message)
|
|
})
|
|
if impErr != nil {
|
|
return nil, impErr
|
|
}
|
|
|
|
// Send final completion message
|
|
var message string
|
|
if result.WarehousePLID > 0 {
|
|
message = fmt.Sprintf("Импорт завершён: добавлено %d позиций, создан прайслист %s", result.Inserted, result.WarehousePLVer)
|
|
} else {
|
|
message = fmt.Sprintf("Импорт завершён: добавлено %d позиций", result.Inserted)
|
|
}
|
|
progressCb(100, message)
|
|
|
|
return map[string]interface{}{
|
|
"rows_total": result.RowsTotal,
|
|
"valid_rows": result.ValidRows,
|
|
"inserted": result.Inserted,
|
|
"deleted": result.Deleted,
|
|
"unmapped": result.Unmapped,
|
|
"conflicts": result.Conflicts,
|
|
"auto_mapped": result.AutoMapped,
|
|
"parse_errors": result.ParseErrors,
|
|
"qty_parse_errors": result.QtyParseErrors,
|
|
"ignored": result.Ignored,
|
|
"import_date": result.ImportDate.Format("2006-01-02"),
|
|
"warehouse_pricelist_id": result.WarehousePLID,
|
|
"warehouse_pricelist_version": result.WarehousePLVer,
|
|
}, nil
|
|
})
|
|
|
|
fmt.Printf("[StockImport] Task submitted: task_id=%s, filename=%s, size=%d bytes, create_pricelist=%v\n", taskID, filename, len(content), createPricelist)
|
|
c.JSON(http.StatusOK, gin.H{"task_id": taskID})
|
|
}
|
|
|
|
func (h *PricingHandler) ListStockMappings(c *gin.Context) {
|
|
if h.stockImportService == 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")
|
|
|
|
rows, total, err := h.stockImportService.ListMappings(page, perPage, search)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"items": rows,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) UpsertStockMapping(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Сопоставления доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Partnumber string `json:"partnumber" binding:"required"`
|
|
LotName string `json:"lot_name"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.stockImportService.UpsertMapping(req.Partnumber, req.LotName, req.Description); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "mapping saved"})
|
|
}
|
|
|
|
func (h *PricingHandler) DeleteStockMapping(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Сопоставления доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
partnumber := c.Param("partnumber")
|
|
deleted, err := h.stockImportService.DeleteMapping(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) ListStockIgnoreRules(c *gin.Context) {
|
|
if h.stockImportService == 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"))
|
|
rows, total, err := h.stockImportService.ListIgnoreRules(page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"items": rows,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) UpsertStockIgnoreRule(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Правила игнорирования доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
var req struct {
|
|
Target string `json:"target" binding:"required"`
|
|
MatchType string `json:"match_type" binding:"required"`
|
|
Pattern string `json:"pattern" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.stockImportService.UpsertIgnoreRule(req.Target, req.MatchType, req.Pattern); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "ignore rule saved"})
|
|
}
|
|
|
|
func (h *PricingHandler) DeleteStockIgnoreRule(c *gin.Context) {
|
|
if h.stockImportService == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Правила игнорирования доступны только в онлайн режиме",
|
|
"offline": true,
|
|
})
|
|
return
|
|
}
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
deleted, err := h.stockImportService.DeleteIgnoreRule(uint(id))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
|
}
|