Renamed module path git.mchus.pro/mchus/quoteforge → git.mchus.pro/mchus/priceforge, renamed package quoteforge → priceforge, moved binary from cmd/qfs to cmd/pfs.
200 lines
5.4 KiB
Go
200 lines
5.4 KiB
Go
package alerts
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/config"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"git.mchus.pro/mchus/priceforge/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)
|
|
}
|