Files
PriceForge/internal/handlers/pricelist.go
Michael Chus 9bc01831c9 feat: add pricelist CSV export and improve description display
- Add CSV export functionality for pricelists with download button
- Export includes all pricelist items with proper UTF-8 encoding
- Support both warehouse and estimate pricelist sources
- Remove description column from admin pricing tables
- Show description as tooltip on row hover instead
- Improve table layout by removing redundant column

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 12:26:45 +03:00

401 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 (
"encoding/csv"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"git.mchus.pro/mchus/priceforge/internal/models"
"git.mchus.pro/mchus/priceforge/internal/services/pricelist"
"github.com/gin-gonic/gin"
)
type PricelistHandler struct {
service *pricelist.Service
dbUser string
}
func NewPricelistHandler(service *pricelist.Service, dbUser string) *PricelistHandler {
return &PricelistHandler{service: service, dbUser: dbUser}
}
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})
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
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)
return
}
send := func(payload gin.H) { c.SSEvent("progress", payload); flusher.Flush() }
send(gin.H{"current": 0, "total": 100, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, items, func(p pricelist.CreateProgress) {
send(gin.H{"current": p.Current, "total": p.Total, "status": p.Status, "message": p.Message, "updated": p.Updated, "errors": p.Errors, "lot_name": p.LotName})
})
if err != nil {
send(gin.H{"status": "error", "message": err.Error()})
return
}
send(gin.H{"current": 100, "total": 100, "status": "completed", "message": "Готово", "pricelist": pl})
}
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
}
// Get all items (no pagination)
items, _, err := h.service.GetItems(uint(id), 1, 999999, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
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))
// Create CSV writer
writer := csv.NewWriter(c.Writer)
defer writer.Flush()
// Write UTF-8 BOM for Excel compatibility
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
// 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.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write CSV header"})
return
}
// Write items
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write CSV row"})
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, " | ")
}