Files
QuoteForge/internal/handlers/pricing.go
Mikhail Chusavitin 8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00

939 lines
25 KiB
Go

package handlers
import (
"net/http"
"sort"
"strconv"
"strings"
"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) {
// Check if we're in offline mode
if h.statsRepo == nil || h.alertService == nil {
c.JSON(http.StatusOK, gin.H{
"new_alerts_count": 0,
"top_components": []interface{}{},
"trending_components": []interface{}{},
"offline": true,
})
return
}
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"`
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
}
func (h *PricingHandler) ListComponents(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil {
c.JSON(http.StatusOK, gin.H{
"components": []ComponentWithCount{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
"message": "Управление ценами доступно только в онлайн режиме",
})
return
}
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)
// Get meta usage information
metaUsage := h.getMetaUsageMap(lotNames)
// Combine components with counts
result := make([]ComponentWithCount, len(components))
for i, comp := range components {
result[i] = ComponentWithCount{
LotMetadata: comp,
QuoteCount: counts[comp.LotName],
UsedInMeta: metaUsage[comp.LotName],
}
}
c.JSON(http.StatusOK, gin.H{
"components": result,
"total": total,
"page": page,
"per_page": perPage,
})
}
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
result := make(map[string][]string)
// Get all components with meta_prices
var metaComponents []models.LotMetadata
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
// Build reverse lookup: which components are used in which meta-articles
for _, meta := range metaComponents {
sources := strings.Split(meta.MetaPrices, ",")
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
// Handle wildcard patterns
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lotName := range lotNames {
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
} else {
// Direct match
for _, lotName := range lotNames {
if lotName == source && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
}
}
}
return result
}
// expandMetaPrices expands meta_prices string to list of actual lot names
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots
prefix := strings.TrimSuffix(source, "*")
var matchingLots []string
h.db.Model(&models.LotMetadata{}).
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
Pluck("lot_name", &matchingLots)
for _, lot := range matchingLots {
if !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if source != excludeLot && !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
// Check if we're in offline mode
if h.componentRepo == nil || h.pricingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление ценами доступно только в онлайн режиме",
"offline": true,
})
return
}
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"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
MetaMethod string `json:"meta_method"`
MetaPeriod int `json:"meta_period"`
IsHidden bool `json:"is_hidden"`
}
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Обновление цен доступно только в онлайн режиме",
"offline": true,
})
return
}
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 meta prices
if req.MetaEnabled && req.MetaPrices != "" {
updates["meta_prices"] = req.MetaPrices
} else {
updates["meta_prices"] = ""
}
// Handle hidden flag
updates["is_hidden"] = req.IsHidden
// 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
}
// Determine which lot names to use for price calculation
lotNames := []string{lotName}
if comp.MetaPrices != "" {
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
}
// Get prices based on period from all relevant lots
var prices []float64
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
}
} else {
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
ln, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
}
prices = append(prices, lotPrices...)
}
// If no prices in period, try all time
if len(prices) == 0 && periodDays > 0 {
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
}
if len(prices) == 0 {
return
}
// Calculate price based on method
sortFloat64s(prices)
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) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Пересчёт цен доступен только в онлайн режиме",
"offline": true,
})
return
}
// 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))
// Pre-load all lot names for efficient wildcard matching
var allLotNames []string
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
lotNameSet := make(map[string]bool, len(allLotNames))
for _, ln := range allLotNames {
lotNameSet[ln] = true
}
// Pre-load latest quote dates for all lots (for checking updates)
type LotDate struct {
Lot string
Date time.Time
}
var latestDates []LotDate
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
// Send initial progress
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
c.Writer.Flush()
// Process components individually to respect their settings
var updated, skipped, manual, unchanged, errors int
now := time.Now()
progressCounter := 0
for _, comp := range components {
progressCounter++
// If manual price is set, skip recalculation
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
goto sendProgress
}
// Calculate price based on component's individual settings
{
periodDays := comp.PricePeriodDays
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
// Determine source lots for price calculation (using cached lot names)
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
goto sendProgress
}
// Check if there are new quotes since last update (using cached dates)
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok {
if latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
}
if !hasNewData {
unchanged++
goto sendProgress
}
}
// Get prices from source lots
var prices []float64
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, periodDays).Pluck("price", &prices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots).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 IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
}
if len(prices) == 0 {
skipped++
goto sendProgress
}
// Calculate price based on method
var basePrice float64
switch method {
case models.PriceMethodMedian:
basePrice = calculateMedian(prices)
case models.PriceMethodAverage:
basePrice = calculateAverage(prices)
default:
basePrice = calculateMedian(prices)
}
if 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 10 components to reduce overhead
if progressCounter%10 == 0 || progressCounter == int(total) {
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "processing",
"lot_name": comp.LotName,
})
c.Writer.Flush()
}
}
// Update popularity scores
h.statsRepo.UpdatePopularityScores()
// Send completion
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "completed",
})
c.Writer.Flush()
}
func (h *PricingHandler) ListAlerts(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusOK, gin.H{
"alerts": []interface{}{},
"total": 0,
"page": 1,
"per_page": 20,
"offline": true,
})
return
}
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) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
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) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
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) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Управление алертами доступно только в онлайн режиме",
"offline": true,
})
return
}
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"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
}
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
// Check if we're in offline mode
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Предпросмотр цены доступен только в онлайн режиме",
"offline": true,
})
return
}
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
}
// Determine which lot names to use for price calculation
lotNames := []string{req.LotName}
if req.MetaEnabled && req.MetaPrices != "" {
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
}
// Get all prices for calculations (from all relevant lots)
var allPrices []float64
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
// Wildcard pattern
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
}
allPrices = append(allPrices, lotPrices...)
}
// Calculate median for all time
var medianAllTime *float64
if len(allPrices) > 0 {
sortFloat64s(allPrices)
median := calculateMedian(allPrices)
medianAllTime = &median
}
// Get quote count (from all relevant lots) - total count
var quoteCountTotal int64
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
} else {
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
}
quoteCountTotal += count
}
// Get quote count for specified period (if period is > 0)
var quoteCountPeriod int64
if req.PeriodDays > 0 {
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
} else {
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
}
quoteCountPeriod += count
}
} else {
// If no period specified, period count equals total count
quoteCountPeriod = quoteCountTotal
}
// Get last received price (from the main lot only)
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 {
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, req.PeriodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, req.PeriodDays).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
// 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 {
sortFloat64s(prices)
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_total": quoteCountTotal,
"quote_count_period": quoteCountPeriod,
"manual_price": comp.ManualPrice,
"last_price": lastPrice.Price,
"last_price_date": lastPrice.Date,
})
}
// sortFloat64s sorts a slice of float64 in ascending order
func sortFloat64s(data []float64) {
sort.Float64s(data)
}
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots from cache
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}