464 lines
13 KiB
Go
464 lines
13 KiB
Go
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("Прайслист создан: %s из источника %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 := "-"
|
||
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, " | ")
|
||
}
|