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, }) }