Add initial backend implementation
- Go module with Gin, GORM, JWT, excelize dependencies - Configuration loading from YAML with all settings - GORM models for users, categories, components, configurations, alerts - Repository layer for all entities - Services: auth (JWT), pricing (median/average/weighted), components, quotes, configurations, export (CSV/XLSX), alerts - Middleware: JWT auth, role-based access, CORS - HTTP handlers for all API endpoints - Main server with dependency injection and graceful shutdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
199
internal/services/alerts/service.go
Normal file
199
internal/services/alerts/service.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/config"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/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)
|
||||
}
|
||||
Reference in New Issue
Block a user