Files
PriceForge/internal/handlers/pricelist.go
Michael Chus f64c4fd6b2 feat: optimize background tasks and fix warehouse pricelist workflow
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>
2026-02-16 11:08:10 +03:00

407 lines
12 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 (
"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, " | ")
}