Files
PriceForge/internal/handlers/pricelist.go
Mikhail Chusavitin c47c93ab31 fix: потоковая отправка прогресса создания прайслиста и исправление маппинга колонки категории
Две ключевые исправления:

1. Потоковая отправка прогресса создания (SSE):
   - Эндпоинт CreateWithProgress теперь отправляет Server-Sent Events
     вместо возврата JSON с task_id
   - Полирует статус задачи и отправляет обновления прогресса в реальном времени
   - Отправляет финальное событие с данными прайслиста или ошибкой
   - Фронтенд уже ожидал этого формата SSE

2. Исправление маппинга колонки lot_category:
   - Добавлен явный тег column в поле Category модели PricelistItem
     чтобы маппиться на колонку 'lot_category' в БД
   - Категория теперь хранится как снимок в таблице pricelist_items
   - Обновлены запросы репозитория для использования сохраненной
     категории вместо динамических JOIN с таблицей lot

Это исправляет ошибки:
- "Создание прервано: не получен результат" (фронтенд ожидал streaming)
- "Unknown column 'category' in 'INSERT INTO'" (несоответствие схемы БД)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-10 15:17:16 +03:00

464 lines
13 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"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"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
})
// Stream task progress as Server-Sent Events
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
lastProgress := -1
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-c.Request.Context().Done():
return
case <-ticker.C:
task, err := h.taskManager.Get(taskID)
if err != nil {
return
}
// Send progress if changed
if task.Progress != lastProgress {
data, _ := json.Marshal(map[string]interface{}{
"status": task.Status,
"current": task.Progress,
"total": 100,
"message": task.Message,
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
c.Writer.Flush()
lastProgress = task.Progress
}
// Check if task is done
if task.Status == tasks.TaskStatusCompleted || task.Status == tasks.TaskStatusError {
// Send final event
if task.Status == tasks.TaskStatusError {
errorData, _ := json.Marshal(map[string]interface{}{
"status": "error",
"message": task.Error,
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData))
} else {
// Extract pricelist from result
resultData := map[string]interface{}{
"status": "completed",
"message": task.Message,
"pricelist": task.Result,
}
data, _ := json.Marshal(resultData)
fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
}
c.Writer.Flush()
return
}
}
}
}
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, " | ")
}