Files
PriceForge/internal/handlers/pricelist.go
Michael Chus e97cd5048c feat: implement background task system with notifications
- Added background task manager with goroutine execution and panic recovery
- Replaced SSE streaming with background task execution for:
  * Price recalculation (RecalculateAll)
  * Stock import (ImportStockLog)
  * Pricelist creation (CreateWithProgress)
- Implemented unified polling for task status and DB connection in frontend
- Added task indicator in top bar showing running tasks count
- Added toast notifications for task completion/error
- Tasks automatically cleaned up after 10 minutes
- Tasks show progress (0-100%) with descriptive messages
- Updated handler constructors to receive task manager
- Added API endpoints for task status (/api/tasks, /api/tasks/:id)

Fixes issue with SSE disconnection on slow connections during long-running operations
2026-02-08 20:39:59 +03:00

407 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 (
"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("Прайслист создан: v%d из источника %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 := item.Category
if category == "" {
category = "-"
}
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, " | ")
}