diff --git a/internal/handlers/pricing.go b/internal/handlers/pricing.go index 4c9f251..b494f75 100644 --- a/internal/handlers/pricing.go +++ b/internal/handlers/pricing.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "sort" "strconv" "time" @@ -13,6 +14,31 @@ import ( "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 @@ -205,30 +231,45 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) { } periodDays := comp.PricePeriodDays - var result struct { - Price *float64 + method := comp.PriceMethod + if method == "" { + method = models.PriceMethodMedian } - // First try with configured period + // Get prices based on period + var prices []float64 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) + 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 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 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 { - query := `SELECT AVG(price) as price FROM lot_log WHERE lot = ?` - h.db.Raw(query, lotName).Scan(&result) + h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices) } - if result.Price == nil || *result.Price <= 0 { + if len(prices) == 0 { return } - finalPrice := *result.Price + // 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) } @@ -258,110 +299,96 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { 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 + // Process components individually to respect their settings 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 + 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 { - // 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 - } + 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) } - // 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{}). + // Update only price fields + err := h.db.Model(&models.LotMetadata{}). Where("lot_name = ?", comp.LotName). - Updates(updates).Error + Updates(map[string]interface{}{ + "current_price": finalPrice, + "price_updated_at": now, + }).Error if err != nil { errors++ } else { @@ -369,22 +396,20 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) { } } - // Commit batch - if err := tx.Commit().Error; err != nil { - errors += len(batch) + 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() } - - // 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 @@ -494,11 +519,16 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { return } - // Get median for all time - var medianAllTime struct { - Price *float64 + // 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 } - h.db.Raw(`SELECT AVG(price) as price FROM lot_log WHERE lot = ?`, req.LotName).Scan(&medianAllTime) // Get quote count var quoteCount int64 @@ -511,31 +541,43 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) { } 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 + // Calculate new price based on parameters (method, period, coefficient) + method := req.Method + if method == "" { + method = "median" + } + + var prices []float64 if req.PeriodDays > 0 { - var result struct { - Price *float64 + 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 } - 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 + prices = allPrices } var newPrice *float64 - if basePrice != nil && *basePrice > 0 { - calculated := *basePrice - if req.Coefficient != 0 { - calculated = calculated * (1 + req.Coefficient/100) + if len(prices) > 0 { + var basePrice float64 + if method == "average" { + basePrice = calculateAverage(prices) + } else { + basePrice = calculateMedian(prices) } - newPrice = &calculated + + 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.Price, + "median_all_time": medianAllTime, "new_price": newPrice, "quote_count": quoteCount, "manual_price": comp.ManualPrice, diff --git a/web/templates/admin_pricing.html b/web/templates/admin_pricing.html index b6aefe5..bad3986 100644 --- a/web/templates/admin_pricing.html +++ b/web/templates/admin_pricing.html @@ -39,11 +39,11 @@ Сортировка: - + @@ -142,8 +142,8 @@ let perPage = 50; let searchTimeout = null; let currentSearch = ''; let componentsCache = []; -let sortField = 'lot_name'; -let sortDir = 'asc'; +let sortField = 'popularity_score'; +let sortDir = 'desc'; async function loadTab(tab) { currentTab = tab; diff --git a/web/templates/index.html b/web/templates/index.html index e88927d..c8c333c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -69,6 +69,66 @@ + + +
| Компонент | +Кол-во | +Было | +Стало | +Итого | +
|---|---|---|---|---|
| Итого | +$0.00 | +$0.00 | +$0.00 | +|