diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index ad06fd2..eefa84d 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -677,8 +677,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect var projectService *services.ProjectService syncService = sync.NewService(connMgr, local) - componentService := services.NewComponentService(nil, nil, nil) - quoteService := services.NewQuoteService(nil, nil, nil, local, nil) + componentService := services.NewComponentService(nil, nil) + quoteService := services.NewQuoteService(nil, nil, local, nil) exportService := services.NewExportService(cfg.Export, local) // isOnline function for local-first architecture diff --git a/internal/models/alert.go b/internal/models/alert.go deleted file mode 100644 index 8f526be..0000000 --- a/internal/models/alert.go +++ /dev/null @@ -1,93 +0,0 @@ -package models - -import ( - "database/sql/driver" - "encoding/json" - "errors" - "time" -) - -type AlertType string - -const ( - AlertHighDemandStalePrice AlertType = "high_demand_stale_price" - AlertPriceSpike AlertType = "price_spike" - AlertPriceDrop AlertType = "price_drop" - AlertNoRecentQuotes AlertType = "no_recent_quotes" - AlertTrendingNoPrice AlertType = "trending_no_price" -) - -type AlertSeverity string - -const ( - SeverityLow AlertSeverity = "low" - SeverityMedium AlertSeverity = "medium" - SeverityHigh AlertSeverity = "high" - SeverityCritical AlertSeverity = "critical" -) - -type AlertStatus string - -const ( - AlertStatusNew AlertStatus = "new" - AlertStatusAcknowledged AlertStatus = "acknowledged" - AlertStatusResolved AlertStatus = "resolved" - AlertStatusIgnored AlertStatus = "ignored" -) - -type AlertDetails map[string]interface{} - -func (d AlertDetails) Value() (driver.Value, error) { - return json.Marshal(d) -} - -func (d *AlertDetails) Scan(value interface{}) error { - if value == nil { - *d = make(AlertDetails) - return nil - } - bytes, ok := value.([]byte) - if !ok { - return errors.New("type assertion to []byte failed") - } - return json.Unmarshal(bytes, d) -} - -type PricingAlert struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - LotName string `gorm:"size:255;not null" json:"lot_name"` - AlertType AlertType `gorm:"type:enum('high_demand_stale_price','price_spike','price_drop','no_recent_quotes','trending_no_price');not null" json:"alert_type"` - Severity AlertSeverity `gorm:"type:enum('low','medium','high','critical');default:'medium'" json:"severity"` - Message string `gorm:"type:text;not null" json:"message"` - Details AlertDetails `gorm:"type:json" json:"details"` - Status AlertStatus `gorm:"type:enum('new','acknowledged','resolved','ignored');default:'new'" json:"status"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` -} - -func (PricingAlert) TableName() string { - return "qt_pricing_alerts" -} - -type TrendDirection string - -const ( - TrendUp TrendDirection = "up" - TrendStable TrendDirection = "stable" - TrendDown TrendDirection = "down" -) - -type ComponentUsageStats struct { - LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"` - QuotesTotal int `gorm:"default:0" json:"quotes_total"` - QuotesLast30d int `gorm:"default:0" json:"quotes_last_30d"` - QuotesLast7d int `gorm:"default:0" json:"quotes_last_7d"` - TotalQuantity int `gorm:"default:0" json:"total_quantity"` - TotalRevenue float64 `gorm:"type:decimal(14,2);default:0" json:"total_revenue"` - TrendDirection TrendDirection `gorm:"type:enum('up','stable','down');default:'stable'" json:"trend_direction"` - TrendPercent float64 `gorm:"type:decimal(5,2);default:0" json:"trend_percent"` - LastUsedAt *time.Time `json:"last_used_at"` -} - -func (ComponentUsageStats) TableName() string { - return "qt_component_usage_stats" -} diff --git a/internal/models/configuration.go b/internal/models/configuration.go index b15ca5c..11af9d0 100644 --- a/internal/models/configuration.go +++ b/internal/models/configuration.go @@ -124,16 +124,3 @@ func (Configuration) TableName() string { return "qt_configurations" } -type PriceOverride struct { - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - LotName string `gorm:"size:255;not null" json:"lot_name"` - Price float64 `gorm:"type:decimal(12,2);not null" json:"price"` - ValidFrom time.Time `gorm:"type:date;not null" json:"valid_from"` - ValidUntil *time.Time `gorm:"type:date" json:"valid_until"` - Reason string `gorm:"type:text" json:"reason"` - CreatedBy uint `gorm:"not null" json:"created_by"` -} - -func (PriceOverride) TableName() string { - return "qt_price_overrides" -} diff --git a/internal/models/models.go b/internal/models/models.go index b2aff1d..7e696ba 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -14,9 +14,6 @@ func AllModels() []interface{} { &LotMetadata{}, &Project{}, &Configuration{}, - &PriceOverride{}, - &PricingAlert{}, - &ComponentUsageStats{}, &Pricelist{}, &PricelistItem{}, } diff --git a/internal/repository/alert.go b/internal/repository/alert.go deleted file mode 100644 index b1025f3..0000000 --- a/internal/repository/alert.go +++ /dev/null @@ -1,91 +0,0 @@ -package repository - -import ( - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type AlertRepository struct { - db *gorm.DB -} - -func NewAlertRepository(db *gorm.DB) *AlertRepository { - return &AlertRepository{db: db} -} - -func (r *AlertRepository) Create(alert *models.PricingAlert) error { - return r.db.Create(alert).Error -} - -func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) { - var alert models.PricingAlert - err := r.db.First(&alert, id).Error - if err != nil { - return nil, err - } - return &alert, nil -} - -func (r *AlertRepository) Update(alert *models.PricingAlert) error { - return r.db.Save(alert).Error -} - -type AlertFilter struct { - Status models.AlertStatus - Severity models.AlertSeverity - Type models.AlertType - LotName string -} - -func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) { - var alerts []models.PricingAlert - var total int64 - - query := r.db.Model(&models.PricingAlert{}) - - if filter.Status != "" { - query = query.Where("status = ?", filter.Status) - } - if filter.Severity != "" { - query = query.Where("severity = ?", filter.Severity) - } - if filter.Type != "" { - query = query.Where("alert_type = ?", filter.Type) - } - if filter.LotName != "" { - query = query.Where("lot_name = ?", filter.LotName) - } - - query.Count(&total) - - err := query. - Order("FIELD(severity, 'critical', 'high', 'medium', 'low')"). - Order("created_at DESC"). - Offset(offset). - Limit(limit). - Find(&alerts).Error - - return alerts, total, err -} - -func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) { - var count int64 - err := r.db.Model(&models.PricingAlert{}). - Where("status = ?", status). - Count(&count).Error - return count, err -} - -func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error { - return r.db.Model(&models.PricingAlert{}). - Where("id = ?", id). - Update("status", status).Error -} - -func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) { - var count int64 - err := r.db.Model(&models.PricingAlert{}). - Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType). - Count(&count).Error - return count > 0, err -} diff --git a/internal/repository/stats.go b/internal/repository/stats.go deleted file mode 100644 index e881c93..0000000 --- a/internal/repository/stats.go +++ /dev/null @@ -1,93 +0,0 @@ -package repository - -import ( - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type StatsRepository struct { - db *gorm.DB -} - -func NewStatsRepository(db *gorm.DB) *StatsRepository { - return &StatsRepository{db: db} -} - -func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) { - var stats models.ComponentUsageStats - err := r.db.Where("lot_name = ?", lotName).First(&stats).Error - if err != nil { - return nil, err - } - return &stats, nil -} - -func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error { - return r.db.Save(stats).Error -} - -func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error { - now := time.Now() - - result := r.db.Model(&models.ComponentUsageStats{}). - Where("lot_name = ?", lotName). - Updates(map[string]interface{}{ - "quotes_total": gorm.Expr("quotes_total + 1"), - "quotes_last_30d": gorm.Expr("quotes_last_30d + 1"), - "quotes_last_7d": gorm.Expr("quotes_last_7d + 1"), - "total_quantity": gorm.Expr("total_quantity + ?", quantity), - "total_revenue": gorm.Expr("total_revenue + ?", revenue), - "last_used_at": now, - }) - - if result.RowsAffected == 0 { - stats := &models.ComponentUsageStats{ - LotName: lotName, - QuotesTotal: 1, - QuotesLast30d: 1, - QuotesLast7d: 1, - TotalQuantity: quantity, - TotalRevenue: revenue, - LastUsedAt: &now, - } - return r.db.Create(stats).Error - } - - return result.Error -} - -func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) { - var stats []models.ComponentUsageStats - err := r.db. - Order("quotes_last_30d DESC"). - Limit(limit). - Find(&stats).Error - return stats, err -} - -func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) { - var stats []models.ComponentUsageStats - err := r.db. - Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20). - Order("trend_percent DESC"). - Limit(limit). - Find(&stats).Error - return stats, err -} - -// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron) -func (r *StatsRepository) ResetWeeklyCounters() error { - return r.db.Model(&models.ComponentUsageStats{}). - Where("1 = 1"). - Update("quotes_last_7d", 0).Error -} - -// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron) -func (r *StatsRepository) ResetMonthlyCounters() error { - return r.db.Model(&models.ComponentUsageStats{}). - Where("1 = 1"). - Update("quotes_last_30d", 0).Error -} - diff --git a/internal/services/component.go b/internal/services/component.go index 8b9b146..ec1e32c 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -12,18 +12,15 @@ import ( type ComponentService struct { componentRepo *repository.ComponentRepository categoryRepo *repository.CategoryRepository - statsRepo *repository.StatsRepository } func NewComponentService( componentRepo *repository.ComponentRepository, categoryRepo *repository.CategoryRepository, - statsRepo *repository.StatsRepository, ) *ComponentService { return &ComponentService{ componentRepo: componentRepo, categoryRepo: categoryRepo, - statsRepo: statsRepo, } } diff --git a/internal/services/configuration.go b/internal/services/configuration.go index 9bcaa3d..9e24e43 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -2,7 +2,6 @@ package services import ( "errors" - "log/slog" "time" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -118,11 +117,6 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq return nil, err } - // Record usage stats (best-effort) - if err := s.quoteService.RecordUsage(req.Items); err != nil { - slog.Warn("configuration: could not record usage stats", "err", err) - } - return config, nil } diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 0d97c62..4f1b102 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -119,11 +119,6 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf } cfg.Line = localCfg.Line - // Record usage stats (best-effort) - if err := s.quoteService.RecordUsage(req.Items); err != nil { - slog.Warn("local configuration: could not record usage stats", "err", err) - } - return cfg, nil } diff --git a/internal/services/quote.go b/internal/services/quote.go index 9cef5ca..12cab8b 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -19,7 +19,6 @@ var ( type QuoteService struct { componentRepo *repository.ComponentRepository - statsRepo *repository.StatsRepository pricelistRepo *repository.PricelistRepository localDB *localdb.LocalDB pricingService priceResolver @@ -34,14 +33,12 @@ type priceResolver interface { func NewQuoteService( componentRepo *repository.ComponentRepository, - statsRepo *repository.StatsRepository, pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB, pricingService priceResolver, ) *QuoteService { return &QuoteService{ componentRepo: componentRepo, - statsRepo: statsRepo, pricelistRepo: pricelistRepo, localDB: localDB, pricingService: pricingService, @@ -504,18 +501,3 @@ func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string return 0, false } -// RecordUsage records that components were used in a quote -func (s *QuoteService) RecordUsage(items []models.ConfigItem) error { - if s.statsRepo == nil { - // Offline mode: usage stats are unavailable and should not block config saves. - return nil - } - - for _, item := range items { - revenue := item.UnitPrice * float64(item.Quantity) - if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil { - return err - } - } - return nil -} diff --git a/internal/services/quote_price_levels_test.go b/internal/services/quote_price_levels_test.go index eb836e2..1b9de91 100644 --- a/internal/services/quote_price_levels_test.go +++ b/internal/services/quote_price_levels_test.go @@ -13,7 +13,7 @@ import ( func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { db := newPriceLevelsTestDB(t) repo := repository.NewPricelistRepository(db) - service := NewQuoteService(nil, nil, repo, nil, nil) + service := NewQuoteService(nil, repo, nil, nil) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) _ = estimate @@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { db := newPriceLevelsTestDB(t) repo := repository.NewPricelistRepository(db) - service := NewQuoteService(nil, nil, repo, nil, nil) + service := NewQuoteService(nil, repo, nil, nil) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)