Optimize task retention from 5 minutes to 30 seconds to reduce polling overhead since toast notifications are shown only once. Add conditional warehouse pricelist creation via checkbox. Fix category storage in warehouse pricelists to properly load from lot table. Replace SSE with task polling for all long operations. Add comprehensive logging for debugging while minimizing noise from polling endpoints. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
407 lines
12 KiB
Go
407 lines
12 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/csv"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"git.mchus.pro/mchus/priceforge/internal/models"
|
||
"git.mchus.pro/mchus/priceforge/internal/services/pricelist"
|
||
"git.mchus.pro/mchus/priceforge/internal/tasks"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type PricelistHandler struct {
|
||
service *pricelist.Service
|
||
dbUser string
|
||
taskManager *tasks.Manager
|
||
}
|
||
|
||
func NewPricelistHandler(service *pricelist.Service, dbUser string, taskManager *tasks.Manager) *PricelistHandler {
|
||
return &PricelistHandler{service: service, dbUser: dbUser, taskManager: taskManager}
|
||
}
|
||
|
||
func (h *PricelistHandler) List(c *gin.Context) {
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||
source := c.Query("source")
|
||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||
|
||
var (
|
||
list []models.PricelistSummary
|
||
total int64
|
||
err error
|
||
)
|
||
if activeOnly {
|
||
list, total, err = h.service.ListActiveBySource(page, perPage, source)
|
||
} else {
|
||
list, total, err = h.service.ListBySource(page, perPage, source)
|
||
}
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"pricelists": list, "total": total, "page": page, "per_page": perPage})
|
||
}
|
||
|
||
func (h *PricelistHandler) Get(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
pl, err := h.service.GetByID(uint(id))
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, pl)
|
||
}
|
||
|
||
func (h *PricelistHandler) Create(c *gin.Context) {
|
||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||
if !canWrite {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "pricelist write is not allowed", "debug": debugInfo})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Source string `json:"source"`
|
||
Items []struct {
|
||
LotName string `json:"lot_name"`
|
||
Price float64 `json:"price"`
|
||
} `json:"items"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
source := string(models.NormalizePricelistSource(req.Source))
|
||
createdBy := h.dbUser
|
||
if strings.TrimSpace(createdBy) == "" {
|
||
createdBy = "unknown"
|
||
}
|
||
items := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
||
for _, item := range req.Items {
|
||
items = append(items, pricelist.CreateItemInput{LotName: item.LotName, Price: item.Price})
|
||
}
|
||
|
||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, items, nil)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusCreated, pl)
|
||
}
|
||
|
||
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||
if !canWrite {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "pricelist write is not allowed", "debug": debugInfo})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Source string `json:"source"`
|
||
Items []struct {
|
||
LotName string `json:"lot_name"`
|
||
Price float64 `json:"price"`
|
||
} `json:"items"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
source := string(models.NormalizePricelistSource(req.Source))
|
||
createdBy := h.dbUser
|
||
if strings.TrimSpace(createdBy) == "" {
|
||
createdBy = "unknown"
|
||
}
|
||
items := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
||
for _, item := range req.Items {
|
||
items = append(items, pricelist.CreateItemInput{LotName: item.LotName, Price: item.Price})
|
||
}
|
||
|
||
taskID := h.taskManager.Submit(tasks.TaskTypePricelistCreate, func(ctx context.Context, progressCb func(int, string)) (map[string]interface{}, error) {
|
||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, items, func(p pricelist.CreateProgress) {
|
||
// 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 err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Send final completion message
|
||
progressCb(100, fmt.Sprintf("Прайслист создан: %s из источника %s", pl.Version, pl.Source))
|
||
|
||
return map[string]interface{}{
|
||
"pricelist_id": pl.ID,
|
||
"pricelist_version": pl.Version,
|
||
"source": pl.Source,
|
||
}, nil
|
||
})
|
||
|
||
c.JSON(http.StatusOK, gin.H{"task_id": taskID})
|
||
}
|
||
|
||
func (h *PricelistHandler) Delete(c *gin.Context) {
|
||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||
if !canWrite {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "pricelist write is not allowed", "debug": debugInfo})
|
||
return
|
||
}
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
if err := h.service.Delete(uint(id)); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
|
||
}
|
||
|
||
func (h *PricelistHandler) SetActive(c *gin.Context) {
|
||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||
if !canWrite {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "pricelist write is not allowed", "debug": debugInfo})
|
||
return
|
||
}
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
var req struct {
|
||
IsActive bool `json:"is_active"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
|
||
}
|
||
|
||
func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||
search := c.Query("search")
|
||
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
|
||
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 *PricelistHandler) GetLotNames(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
lotNames, err := h.service.GetLotNames(uint(id))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"lot_names": lotNames, "total": len(lotNames)})
|
||
}
|
||
|
||
func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||
canWrite, debugInfo := h.service.CanWriteDebug()
|
||
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
|
||
}
|
||
|
||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||
source := string(models.NormalizePricelistSource(c.DefaultQuery("source", string(models.PricelistSourceEstimate))))
|
||
pl, err := h.service.GetLatestActiveBySource(source)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "no pricelists available"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, pl)
|
||
}
|
||
|
||
func (h *PricelistHandler) ExportCSV(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
|
||
return
|
||
}
|
||
|
||
// Get pricelist info
|
||
pl, err := h.service.GetByID(uint(id))
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
|
||
return
|
||
}
|
||
|
||
// Set response headers for CSV download
|
||
filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
|
||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||
|
||
// Write UTF-8 BOM for Excel compatibility
|
||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||
|
||
// Create CSV writer with semicolon separator
|
||
writer := csv.NewWriter(c.Writer)
|
||
writer.Comma = ';'
|
||
defer writer.Flush()
|
||
|
||
// Determine if warehouse source
|
||
isWarehouse := strings.ToLower(pl.Source) == "warehouse"
|
||
|
||
// Write CSV header
|
||
var header []string
|
||
if isWarehouse {
|
||
header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
|
||
} else {
|
||
header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
|
||
}
|
||
if err := writer.Write(header); err != nil {
|
||
c.String(http.StatusInternalServerError, "Failed to write CSV header")
|
||
return
|
||
}
|
||
|
||
// Stream items in batches to avoid loading everything into memory
|
||
err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
|
||
for _, item := range items {
|
||
row := make([]string, 0, len(header))
|
||
|
||
// Артикул
|
||
row = append(row, item.LotName)
|
||
|
||
// Категория
|
||
category := "-"
|
||
if item.LotCategory != nil && *item.LotCategory != "" {
|
||
category = *item.LotCategory
|
||
}
|
||
row = append(row, category)
|
||
|
||
// Описание
|
||
description := item.LotDescription
|
||
if description == "" {
|
||
description = "-"
|
||
}
|
||
row = append(row, description)
|
||
|
||
if isWarehouse {
|
||
// Доступно
|
||
qty := "-"
|
||
if item.AvailableQty != nil {
|
||
qty = fmt.Sprintf("%.3f", *item.AvailableQty)
|
||
}
|
||
row = append(row, qty)
|
||
|
||
// Partnumbers
|
||
partnumbers := "-"
|
||
if len(item.Partnumbers) > 0 {
|
||
partnumbers = strings.Join(item.Partnumbers, ", ")
|
||
}
|
||
row = append(row, partnumbers)
|
||
}
|
||
|
||
// Цена
|
||
row = append(row, fmt.Sprintf("%.2f", item.Price))
|
||
|
||
// Настройки
|
||
settings := formatPriceSettings(item)
|
||
row = append(row, settings)
|
||
|
||
if err := writer.Write(row); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Flush after each batch
|
||
writer.Flush()
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
// Already started writing, can't return JSON error
|
||
c.String(http.StatusInternalServerError, "Export failed: %v", err)
|
||
return
|
||
}
|
||
}
|
||
|
||
func formatPriceSettings(item models.PricelistItem) string {
|
||
var settings []string
|
||
|
||
hasManualPrice := item.ManualPrice != nil && *item.ManualPrice > 0
|
||
hasMeta := item.MetaPrices != ""
|
||
method := strings.ToLower(item.PriceMethod)
|
||
|
||
// Method indicator
|
||
if hasManualPrice {
|
||
settings = append(settings, "РУЧН")
|
||
} else if method == "average" {
|
||
settings = append(settings, "Сред")
|
||
} else if method == "weighted_median" {
|
||
settings = append(settings, "Взвеш. мед")
|
||
} else {
|
||
settings = append(settings, "Мед")
|
||
}
|
||
|
||
// Period (only if not manual price)
|
||
if !hasManualPrice {
|
||
period := item.PricePeriodDays
|
||
switch period {
|
||
case 7:
|
||
settings = append(settings, "1н")
|
||
case 30:
|
||
settings = append(settings, "1м")
|
||
case 90:
|
||
settings = append(settings, "3м")
|
||
case 365:
|
||
settings = append(settings, "1г")
|
||
case 0:
|
||
settings = append(settings, "все")
|
||
default:
|
||
settings = append(settings, fmt.Sprintf("%dд", period))
|
||
}
|
||
}
|
||
|
||
// Coefficient
|
||
if item.PriceCoefficient != 0 {
|
||
coef := item.PriceCoefficient
|
||
if coef > 0 {
|
||
settings = append(settings, fmt.Sprintf("+%.0f%%", coef))
|
||
} else {
|
||
settings = append(settings, fmt.Sprintf("%.0f%%", coef))
|
||
}
|
||
}
|
||
|
||
// Meta article indicator
|
||
if hasMeta {
|
||
settings = append(settings, "МЕТА")
|
||
}
|
||
|
||
if len(settings) == 0 {
|
||
return "-"
|
||
}
|
||
return strings.Join(settings, " | ")
|
||
}
|