- Добавлено отображение последней полученной цены в окне настройки цены - Добавлен функционал переименования конфигураций (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>
546 lines
14 KiB
Go
546 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
|
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type PricingHandler struct {
|
|
db *gorm.DB
|
|
pricingService *pricing.Service
|
|
alertService *alerts.Service
|
|
componentRepo *repository.ComponentRepository
|
|
priceRepo *repository.PriceRepository
|
|
statsRepo *repository.StatsRepository
|
|
}
|
|
|
|
func NewPricingHandler(
|
|
db *gorm.DB,
|
|
pricingService *pricing.Service,
|
|
alertService *alerts.Service,
|
|
componentRepo *repository.ComponentRepository,
|
|
priceRepo *repository.PriceRepository,
|
|
statsRepo *repository.StatsRepository,
|
|
) *PricingHandler {
|
|
return &PricingHandler{
|
|
db: db,
|
|
pricingService: pricingService,
|
|
alertService: alertService,
|
|
componentRepo: componentRepo,
|
|
priceRepo: priceRepo,
|
|
statsRepo: statsRepo,
|
|
}
|
|
}
|
|
|
|
func (h *PricingHandler) GetStats(c *gin.Context) {
|
|
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
|
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
|
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"new_alerts_count": newAlerts,
|
|
"top_components": topComponents,
|
|
"trending_components": trendingComponents,
|
|
})
|
|
}
|
|
|
|
type ComponentWithCount struct {
|
|
models.LotMetadata
|
|
QuoteCount int64 `json:"quote_count"`
|
|
}
|
|
|
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
|
|
filter := repository.ComponentFilter{
|
|
Category: c.Query("category"),
|
|
Search: c.Query("search"),
|
|
SortField: c.Query("sort"),
|
|
SortDir: c.Query("dir"),
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 || perPage > 100 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
|
|
components, total, err := h.componentRepo.List(filter, offset, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get quote counts
|
|
lotNames := make([]string, len(components))
|
|
for i, comp := range components {
|
|
lotNames[i] = comp.LotName
|
|
}
|
|
|
|
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
|
|
|
|
// Combine components with counts
|
|
result := make([]ComponentWithCount, len(components))
|
|
for i, comp := range components {
|
|
result[i] = ComponentWithCount{
|
|
LotMetadata: comp,
|
|
QuoteCount: counts[comp.LotName],
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"components": result,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
|
lotName := c.Param("lot_name")
|
|
|
|
component, err := h.componentRepo.GetByLotName(lotName)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
|
return
|
|
}
|
|
|
|
stats, err := h.pricingService.GetPriceStats(lotName, 0)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"component": component,
|
|
"price_stats": stats,
|
|
})
|
|
}
|
|
|
|
type UpdatePriceRequest struct {
|
|
LotName string `json:"lot_name" binding:"required"`
|
|
Method models.PriceMethod `json:"method"`
|
|
PeriodDays int `json:"period_days"`
|
|
Coefficient float64 `json:"coefficient"`
|
|
ManualPrice *float64 `json:"manual_price"`
|
|
ClearManual bool `json:"clear_manual"`
|
|
}
|
|
|
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
|
var req UpdatePriceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
// Update method if specified
|
|
if req.Method != "" {
|
|
updates["price_method"] = req.Method
|
|
}
|
|
|
|
// Update period days
|
|
if req.PeriodDays >= 0 {
|
|
updates["price_period_days"] = req.PeriodDays
|
|
}
|
|
|
|
// Update coefficient
|
|
updates["price_coefficient"] = req.Coefficient
|
|
|
|
// Handle manual price
|
|
if req.ClearManual {
|
|
updates["manual_price"] = nil
|
|
} else if req.ManualPrice != nil {
|
|
updates["manual_price"] = *req.ManualPrice
|
|
// Also update current price immediately when setting manual
|
|
updates["current_price"] = *req.ManualPrice
|
|
updates["price_updated_at"] = time.Now()
|
|
}
|
|
|
|
err := h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", req.LotName).
|
|
Updates(updates).Error
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Recalculate price if not using manual price
|
|
if req.ManualPrice == nil {
|
|
h.recalculateSinglePrice(req.LotName)
|
|
}
|
|
|
|
// Get updated component to return new price
|
|
var comp models.LotMetadata
|
|
h.db.Where("lot_name = ?", req.LotName).First(&comp)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "price updated",
|
|
"current_price": comp.CurrentPrice,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|
var comp models.LotMetadata
|
|
if err := h.db.Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
|
|
return
|
|
}
|
|
|
|
// Skip if manual price is set
|
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
|
return
|
|
}
|
|
|
|
periodDays := comp.PricePeriodDays
|
|
var result struct {
|
|
Price *float64
|
|
}
|
|
|
|
// First try with configured period
|
|
if periodDays > 0 {
|
|
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 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)
|
|
}
|
|
} else {
|
|
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 {
|
|
return
|
|
}
|
|
|
|
finalPrice := *result.Price
|
|
if comp.PriceCoefficient != 0 {
|
|
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
|
}
|
|
|
|
now := time.Now()
|
|
// Only update price, preserve all user settings
|
|
h.db.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", lotName).
|
|
Updates(map[string]interface{}{
|
|
"current_price": finalPrice,
|
|
"price_updated_at": now,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|
// Set headers for SSE
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
// Get all components with their settings
|
|
var components []models.LotMetadata
|
|
h.db.Find(&components)
|
|
total := int64(len(components))
|
|
|
|
// Send initial progress
|
|
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
|
|
c.Writer.Flush()
|
|
|
|
lotNames := make([]string, 0, len(components))
|
|
for i := range components {
|
|
lotNames = append(lotNames, components[i].LotName)
|
|
}
|
|
|
|
// Batch query: Get average prices with date info for all lots
|
|
// This query returns both all-time average and days since last quote
|
|
type PriceResult struct {
|
|
Lot string `gorm:"column:lot"`
|
|
AvgPrice *float64 `gorm:"column:avg_price"`
|
|
AvgPrice90 *float64 `gorm:"column:avg_price_90"`
|
|
LastQuoteAge *int `gorm:"column:last_quote_age"`
|
|
}
|
|
var priceResults []PriceResult
|
|
h.db.Raw(`
|
|
SELECT
|
|
lot,
|
|
AVG(price) as avg_price,
|
|
AVG(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN price END) as avg_price_90,
|
|
DATEDIFF(NOW(), MAX(date)) as last_quote_age
|
|
FROM lot_log
|
|
WHERE lot IN ?
|
|
GROUP BY lot
|
|
`, lotNames).Scan(&priceResults)
|
|
|
|
// Build price maps
|
|
priceMap := make(map[string]PriceResult, len(priceResults))
|
|
for _, p := range priceResults {
|
|
priceMap[p.Lot] = p
|
|
}
|
|
|
|
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
|
|
c.Writer.Flush()
|
|
|
|
// Process components and prepare batch updates
|
|
var updated, skipped, manual, errors int
|
|
now := time.Now()
|
|
batchSize := 200
|
|
|
|
for i := 0; i < len(components); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(components) {
|
|
end = len(components)
|
|
}
|
|
batch := components[i:end]
|
|
|
|
// Start transaction for batch updates
|
|
tx := h.db.Begin()
|
|
|
|
for _, comp := range batch {
|
|
// If manual price is set, use it
|
|
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
|
err := tx.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", comp.LotName).
|
|
Updates(map[string]interface{}{
|
|
"current_price": *comp.ManualPrice,
|
|
"price_updated_at": now,
|
|
}).Error
|
|
if err != nil {
|
|
errors++
|
|
} else {
|
|
manual++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Get price data for this component
|
|
priceData, hasPrices := priceMap[comp.LotName]
|
|
if !hasPrices || priceData.AvgPrice == nil || *priceData.AvgPrice <= 0 {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Determine which price to use based on component settings
|
|
var finalPrice float64
|
|
periodDays := comp.PricePeriodDays
|
|
|
|
if periodDays <= 0 {
|
|
// 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 if no data in period, but keep user's period setting
|
|
finalPrice = *priceData.AvgPrice
|
|
}
|
|
}
|
|
|
|
// Apply coefficient
|
|
if comp.PriceCoefficient != 0 {
|
|
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,
|
|
}
|
|
|
|
err := tx.Model(&models.LotMetadata{}).
|
|
Where("lot_name = ?", comp.LotName).
|
|
Updates(updates).Error
|
|
if err != nil {
|
|
errors++
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
|
|
// Commit batch
|
|
if err := tx.Commit().Error; err != nil {
|
|
errors += len(batch)
|
|
}
|
|
|
|
// Send progress update
|
|
c.SSEvent("progress", gin.H{
|
|
"current": updated + skipped + manual + errors,
|
|
"total": total,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"manual": manual,
|
|
"errors": errors,
|
|
"status": "processing",
|
|
})
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
// Update popularity scores
|
|
h.statsRepo.UpdatePopularityScores()
|
|
|
|
// Send completion
|
|
c.SSEvent("progress", gin.H{
|
|
"current": updated + skipped + manual + errors,
|
|
"total": total,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
"manual": manual,
|
|
"errors": errors,
|
|
"status": "completed",
|
|
})
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
|
|
|
filter := repository.AlertFilter{
|
|
Status: models.AlertStatus(c.Query("status")),
|
|
Severity: models.AlertSeverity(c.Query("severity")),
|
|
Type: models.AlertType(c.Query("type")),
|
|
LotName: c.Query("lot_name"),
|
|
}
|
|
|
|
alertsList, total, err := h.alertService.List(filter, page, perPage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"alerts": alertsList,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": perPage,
|
|
})
|
|
}
|
|
|
|
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Acknowledge(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "acknowledged"})
|
|
}
|
|
|
|
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Resolve(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "resolved"})
|
|
}
|
|
|
|
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
|
return
|
|
}
|
|
|
|
if err := h.alertService.Ignore(uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "ignored"})
|
|
}
|
|
|
|
type PreviewPriceRequest struct {
|
|
LotName string `json:"lot_name" binding:"required"`
|
|
Method string `json:"method"`
|
|
PeriodDays int `json:"period_days"`
|
|
Coefficient float64 `json:"coefficient"`
|
|
}
|
|
|
|
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|
var req PreviewPriceRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get component
|
|
var comp models.LotMetadata
|
|
if err := h.db.Where("lot_name = ?", req.LotName).First(&comp).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
|
|
return
|
|
}
|
|
|
|
// Get median for all time
|
|
var medianAllTime struct {
|
|
Price *float64
|
|
}
|
|
h.db.Raw(`SELECT AVG(price) as price FROM lot_log WHERE lot = ?`, req.LotName).Scan(&medianAllTime)
|
|
|
|
// Get quote count
|
|
var quoteCount int64
|
|
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count("eCount)
|
|
|
|
// 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 {
|
|
var result struct {
|
|
Price *float64
|
|
}
|
|
h.db.Raw(`SELECT AVG(price) as price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, req.LotName, req.PeriodDays).Scan(&result)
|
|
basePrice = result.Price
|
|
} else {
|
|
basePrice = medianAllTime.Price
|
|
}
|
|
|
|
var newPrice *float64
|
|
if basePrice != nil && *basePrice > 0 {
|
|
calculated := *basePrice
|
|
if req.Coefficient != 0 {
|
|
calculated = calculated * (1 + req.Coefficient/100)
|
|
}
|
|
newPrice = &calculated
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"lot_name": req.LotName,
|
|
"current_price": comp.CurrentPrice,
|
|
"median_all_time": medianAllTime.Price,
|
|
"new_price": newPrice,
|
|
"quote_count": quoteCount,
|
|
"manual_price": comp.ManualPrice,
|
|
"last_price": lastPrice.Price,
|
|
"last_price_date": lastPrice.Date,
|
|
})
|
|
}
|