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

- Добавлено отображение последней полученной цены в окне настройки цены
- Добавлен функционал переименования конфигураций (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,
})
}

View File

@@ -16,9 +16,11 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
}
type ComponentFilter struct {
Category string
Search string
HasPrice bool
Category string
Search string
HasPrice bool
SortField string
SortDir string
}
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
@@ -43,10 +45,33 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
query.Count(&total)
// Sort by popularity + freshness, no price goes last
// Apply sorting
sortDir := "ASC"
if filter.SortDir == "desc" {
sortDir = "DESC"
}
switch filter.SortField {
case "popularity_score":
query = query.Order("popularity_score " + sortDir)
case "current_price":
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("current_price " + sortDir)
case "lot_name":
query = query.Order("lot_name " + sortDir)
case "quote_count":
// Sort by quote count from lot_log table
query = query.
Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort").
Order("quote_count_sort " + sortDir)
default:
// Default: sort by popularity, no price goes last
query = query.
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("popularity_score DESC")
}
err := query.
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("popularity_score DESC").
Offset(offset).
Limit(limit).
Find(&components).Error

View File

@@ -90,3 +90,26 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
Where("1 = 1").
Update("quotes_last_30d", 0).Error
}
// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata
// based on supplier quotes from lot_log table
func (r *StatsRepository) UpdatePopularityScores() error {
// Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1
// This gives more weight to recent supplier activity
return r.db.Exec(`
UPDATE qt_lot_metadata m
LEFT JOIN (
SELECT
lot,
COUNT(*) as quotes_total,
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d,
SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d
FROM lot_log
GROUP BY lot
) s ON m.lot_name = s.lot
SET m.popularity_score = COALESCE(
s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1,
0
)
`).Error
}

View File

@@ -114,6 +114,25 @@ func (s *ConfigurationService) Delete(uuid string, userID uint) error {
return s.configRepo.Delete(config.ID)
}
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if config.UserID != userID {
return nil, ErrConfigForbidden
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1