package handlers import ( "net/http" "sort" "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" ) // calculateMedian returns the median of a sorted slice of prices func calculateMedian(prices []float64) float64 { if len(prices) == 0 { return 0 } sort.Float64s(prices) n := len(prices) if n%2 == 0 { return (prices[n/2-1] + prices[n/2]) / 2 } return prices[n/2] } // calculateAverage returns the arithmetic mean of prices func calculateAverage(prices []float64) float64 { if len(prices) == 0 { return 0 } var sum float64 for _, p := range prices { sum += p } return sum / float64(len(prices)) } 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 method := comp.PriceMethod if method == "" { method = models.PriceMethodMedian } // Get prices based on period var prices []float64 if periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, lotName, periodDays).Pluck("price", &prices) // If no prices in period, try all time if len(prices) == 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices) } } else { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices) } if len(prices) == 0 { return } // Calculate price based on method var finalPrice float64 switch method { case models.PriceMethodMedian: finalPrice = calculateMedian(prices) case models.PriceMethodAverage: finalPrice = calculateAverage(prices) default: finalPrice = calculateMedian(prices) } if finalPrice <= 0 { return } // Apply coefficient 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() c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0}) c.Writer.Flush() // Process components individually to respect their settings var updated, skipped, manual, errors int now := time.Now() for i, comp := range components { // If manual price is set, use it if comp.ManualPrice != nil && *comp.ManualPrice > 0 { err := h.db.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++ } goto sendProgress } // Calculate price based on component's individual settings { var basePrice *float64 periodDays := comp.PricePeriodDays method := comp.PriceMethod if method == "" { method = models.PriceMethodMedian } // Build query based on period var query string var args []interface{} if periodDays > 0 { query = `SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price` args = []interface{}{comp.LotName, periodDays} } else { query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price` args = []interface{}{comp.LotName} } var prices []float64 h.db.Raw(query, args...).Pluck("price", &prices) // If no prices in period, try all time if len(prices) == 0 && periodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, comp.LotName).Pluck("price", &prices) } if len(prices) == 0 { skipped++ goto sendProgress } // Calculate price based on method switch method { case models.PriceMethodMedian: median := calculateMedian(prices) basePrice = &median case models.PriceMethodAverage: avg := calculateAverage(prices) basePrice = &avg default: median := calculateMedian(prices) basePrice = &median } if basePrice == nil || *basePrice <= 0 { skipped++ goto sendProgress } finalPrice := *basePrice // Apply coefficient if comp.PriceCoefficient != 0 { finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) } // Update only price fields err := h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", comp.LotName). Updates(map[string]interface{}{ "current_price": finalPrice, "price_updated_at": now, }).Error if err != nil { errors++ } else { updated++ } } sendProgress: // Send progress update every 50 components if (i+1)%50 == 0 || i == len(components)-1 { 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 all prices for calculations var allPrices []float64 h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, req.LotName).Pluck("price", &allPrices) // Calculate median for all time var medianAllTime *float64 if len(allPrices) > 0 { median := calculateMedian(allPrices) medianAllTime = &median } // 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 (method, period, coefficient) method := req.Method if method == "" { method = "median" } var prices []float64 if req.PeriodDays > 0 { h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, req.LotName, req.PeriodDays).Pluck("price", &prices) // Fall back to all time if no prices in period if len(prices) == 0 { prices = allPrices } } else { prices = allPrices } var newPrice *float64 if len(prices) > 0 { var basePrice float64 if method == "average" { basePrice = calculateAverage(prices) } else { basePrice = calculateMedian(prices) } if req.Coefficient != 0 { basePrice = basePrice * (1 + req.Coefficient/100) } newPrice = &basePrice } c.JSON(http.StatusOK, gin.H{ "lot_name": req.LotName, "current_price": comp.CurrentPrice, "median_all_time": medianAllTime, "new_price": newPrice, "quote_count": quoteCount, "manual_price": comp.ManualPrice, "last_price": lastPrice.Price, "last_price_date": lastPrice.Date, }) }