package handlers import ( "net/http" "sort" "strings" "git.mchus.pro/mchus/priceforge/internal/config" "git.mchus.pro/mchus/priceforge/internal/dbutil" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/repository" "git.mchus.pro/mchus/priceforge/internal/services" "git.mchus.pro/mchus/priceforge/internal/services/alerts" "git.mchus.pro/mchus/priceforge/internal/services/pricing" "git.mchus.pro/mchus/priceforge/internal/tasks" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const maxStockImportFileSize int64 = 25 * 1024 * 1024 const maxVendorMappingsCSVImportFileSize int64 = 10 * 1024 * 1024 // 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)) } // sortFloat64s sorts a slice of float64 in ascending order func sortFloat64s(data []float64) { sort.Float64s(data) } type PricingHandler struct { db *gorm.DB pricingService *pricing.Service alertService *alerts.Service componentRepo *repository.ComponentRepository componentService *services.ComponentService priceRepo *repository.PriceRepository statsRepo *repository.StatsRepository stockImportService *services.StockImportService vendorMapService *services.VendorMappingService partnumberBookService *services.PartnumberBookService schedulerConfig config.SchedulerConfig dbUsername string taskManager *tasks.Manager } func NewPricingHandler( db *gorm.DB, pricingService *pricing.Service, alertService *alerts.Service, componentRepo *repository.ComponentRepository, componentService *services.ComponentService, priceRepo *repository.PriceRepository, statsRepo *repository.StatsRepository, stockImportService *services.StockImportService, vendorMapService *services.VendorMappingService, partnumberBookService *services.PartnumberBookService, schedulerConfig config.SchedulerConfig, dbUsername string, taskManager *tasks.Manager, ) *PricingHandler { return &PricingHandler{ db: db, pricingService: pricingService, alertService: alertService, componentRepo: componentRepo, componentService: componentService, priceRepo: priceRepo, statsRepo: statsRepo, stockImportService: stockImportService, vendorMapService: vendorMapService, partnumberBookService: partnumberBookService, schedulerConfig: schedulerConfig, dbUsername: dbUsername, taskManager: taskManager, } } // queryLotPrices fetches prices from lot_log with timeout and retry func (h *PricingHandler) queryLotPrices(lotName string, periodDays int) ([]float64, error) { var prices []float64 query := dbutil.DefaultQueryTimeout(h.db) var err error if strings.HasSuffix(lotName, "*") { pattern := strings.TrimSuffix(lotName, "*") + "%" if periodDays > 0 { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, pattern, periodDays).Pluck("price", &prices).Error }) } else { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &prices).Error }) } } else { if periodDays > 0 { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, lotName, periodDays).Pluck("price", &prices).Error }) } else { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices).Error }) } } return prices, err } // queryMultipleLotPrices fetches prices from multiple lots with timeout and retry func (h *PricingHandler) queryMultipleLotPrices(lotNames []string, periodDays int) ([]float64, error) { var prices []float64 query := dbutil.DefaultQueryTimeout(h.db) var err error if periodDays > 0 { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, lotNames, periodDays).Pluck("price", &prices).Error }) } else { err = query.Execute(func(db *gorm.DB) error { return db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, lotNames).Pluck("price", &prices).Error }) } return prices, err } // 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. // baseLot is always included as a source, then meta sources are added without duplicates. func (h *PricingHandler) expandMetaPrices(metaPrices, baseLot string) []string { sources := strings.Split(metaPrices, ",") var result []string seen := make(map[string]bool) if baseLot != "" { result = append(result, baseLot) seen[baseLot] = true } 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 ?", prefix+"%"). Pluck("lot_name", &matchingLots) for _, lot := range matchingLots { if !seen[lot] { result = append(result, lot) seen[lot] = true } } } else if !seen[source] { result = append(result, source) seen[source] = true } } return result } // expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries). // baseLot is always included as a source, then meta sources are added without duplicates. func expandMetaPricesWithCache(metaPrices, baseLot string, allLotNames []string) []string { sources := strings.Split(metaPrices, ",") var result []string seen := make(map[string]bool) if baseLot != "" { result = append(result, baseLot) seen[baseLot] = true } for _, source := range sources { source = strings.TrimSpace(source) if source == "" { 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) && !seen[lot] { result = append(result, lot) seen[lot] = true } } } else if !seen[source] { result = append(result, source) seen[source] = true } } return result } 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, }) }