Улучшения управления ценами и конфигурациями

- Добавлено отображение последней полученной цены в окне настройки цены
- Добавлен функционал переименования конфигураций (PATCH /api/configs/:uuid/rename)
- Изменён формат имени файла при экспорте: "YYYY-MM-DD NAME SPEC.ext"
- Исправлена сортировка компонентов: перенесена на сервер для корректной работы с пагинацией
- Добавлен расчёт popularity_score на основе котировок из lot_log
- Исправлена потеря настроек (метод, период, коэффициент) при пересчёте цен

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-27 11:39:12 +03:00
parent d7d6e9d62c
commit 7ded78f2c3
9 changed files with 269 additions and 73 deletions

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"io"
"net/http"
"strconv"
@@ -123,17 +124,53 @@ func (h *ConfigurationHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
type RenameConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Rename(c *gin.Context) {
userID := middleware.GetUserID(c)
uuid := c.Param("uuid")
var req RenameConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Rename(uuid, userID, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
userID := middleware.GetUserID(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
data, err := h.configService.ExportJSON(uuid, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename=config.json")
filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "application/json", data)
}

View File

@@ -54,8 +54,8 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
return
}
filename := fmt.Sprintf("%s_%s.csv", req.Name, time.Now().Format("20060102"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
}
@@ -101,8 +101,8 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
return
}
filename := fmt.Sprintf("%s_%s.csv", config.Name, config.CreatedAt.Format("20060102"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
}

View File

@@ -62,8 +62,10 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
filter := repository.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
Category: c.Query("category"),
Search: c.Query("search"),
SortField: c.Query("sort"),
SortDir: c.Query("dir"),
}
if page < 1 {
@@ -203,7 +205,6 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
}
periodDays := comp.PricePeriodDays
usedAllTime := false
var result struct {
Price *float64
}
@@ -213,13 +214,10 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`
h.db.Raw(query, lotName, periodDays).Scan(&result)
// If no prices found in period, fall back to all time
// If no prices found in period, fall back to all time but keep user's period setting
if result.Price == nil || *result.Price <= 0 {
query = `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
h.db.Raw(query, lotName).Scan(&result)
if result.Price != nil && *result.Price > 0 {
usedAllTime = true
}
}
} else {
query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ?`
@@ -236,19 +234,13 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
}
now := time.Now()
updates := map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}
// If we fell back to all time, update the period setting
if usedAllTime {
updates["price_period_days"] = 0
}
// Only update price, preserve all user settings
h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", lotName).
Updates(updates)
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
})
}
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
@@ -339,22 +331,20 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
continue
}
// Determine which price to use
// Determine which price to use based on component settings
var finalPrice float64
usedAllTime := false
periodDays := comp.PricePeriodDays
if periodDays <= 0 {
// Already using all time
// Use all time
finalPrice = *priceData.AvgPrice
} else {
// Try period price first (using 90-day as proxy)
if priceData.AvgPrice90 != nil && *priceData.AvgPrice90 > 0 {
finalPrice = *priceData.AvgPrice90
} else {
// Fall back to all time
// Fall back to all time if no data in period, but keep user's period setting
finalPrice = *priceData.AvgPrice
usedAllTime = true
}
}
@@ -363,15 +353,12 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
// Only update current_price and price_updated_at, preserve all other settings
updates := map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}
if usedAllTime {
updates["price_period_days"] = 0
}
err := tx.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(updates).Error
@@ -400,6 +387,9 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
c.Writer.Flush()
}
// Update popularity scores
h.statsRepo.UpdatePopularityScores()
// Send completion
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + errors,
@@ -514,6 +504,13 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
var quoteCount int64
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count(&quoteCount)
// Get last received price
var lastPrice struct {
Price *float64
Date *time.Time
}
h.db.Raw(`SELECT price, date FROM lot_log WHERE lot = ? ORDER BY date DESC, lot_log_id DESC LIMIT 1`, req.LotName).Scan(&lastPrice)
// Calculate new price based on parameters
var basePrice *float64
if req.PeriodDays > 0 {
@@ -542,5 +539,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
"new_price": newPrice,
"quote_count": quoteCount,
"manual_price": comp.ManualPrice,
"last_price": lastPrice.Price,
"last_price_date": lastPrice.Date,
})
}