package alerts import ( "fmt" "time" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" ) type Service struct { alertRepo *repository.AlertRepository componentRepo *repository.ComponentRepository priceRepo *repository.PriceRepository statsRepo *repository.StatsRepository config config.AlertsConfig pricingConfig config.PricingConfig } func NewService( alertRepo *repository.AlertRepository, componentRepo *repository.ComponentRepository, priceRepo *repository.PriceRepository, statsRepo *repository.StatsRepository, alertCfg config.AlertsConfig, pricingCfg config.PricingConfig, ) *Service { return &Service{ alertRepo: alertRepo, componentRepo: componentRepo, priceRepo: priceRepo, statsRepo: statsRepo, config: alertCfg, pricingConfig: pricingCfg, } } func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) { if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } offset := (page - 1) * perPage return s.alertRepo.List(filter, offset, perPage) } func (s *Service) Acknowledge(id uint) error { return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged) } func (s *Service) Resolve(id uint) error { return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved) } func (s *Service) Ignore(id uint) error { return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored) } func (s *Service) GetNewAlertsCount() (int64, error) { return s.alertRepo.CountByStatus(models.AlertStatusNew) } // CheckAndGenerateAlerts scans components and creates alerts func (s *Service) CheckAndGenerateAlerts() error { if !s.config.Enabled { return nil } // Get top components by usage topComponents, err := s.statsRepo.GetTopComponents(100) if err != nil { return err } for _, stats := range topComponents { component, err := s.componentRepo.GetByLotName(stats.LotName) if err != nil { continue } // Check high demand + stale price if err := s.checkHighDemandStalePrice(component, &stats); err != nil { continue } // Check trending without price if err := s.checkTrendingNoPrice(component, &stats); err != nil { continue } // Check no recent quotes if err := s.checkNoRecentQuotes(component, &stats); err != nil { continue } } return nil } func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { // high_demand_stale_price: >= 5 quotes/month AND price > 60 days old if stats.QuotesLast30d < s.config.HighDemandThreshold { return nil } if comp.PriceUpdatedAt == nil { return nil } daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24) if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays { return nil } // Check if alert already exists exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice) if exists { return nil } alert := &models.PricingAlert{ LotName: comp.LotName, AlertType: models.AlertHighDemandStalePrice, Severity: models.SeverityCritical, Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate), Details: models.AlertDetails{ "quotes_30d": stats.QuotesLast30d, "days_since_update": daysSinceUpdate, }, } return s.alertRepo.Create(alert) } func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { // trending_no_price: trend > 50% AND no price if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) { return nil } if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 { return nil } exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice) if exists { return nil } alert := &models.PricingAlert{ LotName: comp.LotName, AlertType: models.AlertTrendingNoPrice, Severity: models.SeverityHigh, Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent), Details: models.AlertDetails{ "trend_percent": stats.TrendPercent, }, } return s.alertRepo.Create(alert) } func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error { // no_recent_quotes: popular component, no supplier quotes > 90 days if stats.QuotesLast30d < 3 { return nil } quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays) if err != nil { return err } if quoteCount > 0 { return nil } exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes) if exists { return nil } alert := &models.PricingAlert{ LotName: comp.LotName, AlertType: models.AlertNoRecentQuotes, Severity: models.SeverityMedium, Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays), Details: models.AlertDetails{ "quotes_30d": stats.QuotesLast30d, "no_quotes_days": s.pricingConfig.FreshnessRedDays, }, } return s.alertRepo.Create(alert) }