package pricing import ( "strings" "time" "git.mchus.pro/mchus/priceforge/internal/config" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/repository" "gorm.io/gorm" ) type Service struct { componentRepo *repository.ComponentRepository priceRepo *repository.PriceRepository config config.PricingConfig db *gorm.DB } type RecalculateProgress struct { Current int Total int LotName string Updated int Errors int } func NewService( componentRepo *repository.ComponentRepository, priceRepo *repository.PriceRepository, cfg config.PricingConfig, ) *Service { var db *gorm.DB if componentRepo != nil { db = componentRepo.DB() } return &Service{ componentRepo: componentRepo, priceRepo: priceRepo, config: cfg, db: db, } } // GetEffectivePrice returns the current effective price for a component // Priority: active override > calculated price > nil func (s *Service) GetEffectivePrice(lotName string) (*float64, error) { // Check for active override first override, err := s.priceRepo.GetPriceOverride(lotName) if err == nil && override != nil { return &override.Price, nil } // Get component metadata component, err := s.componentRepo.GetByLotName(lotName) if err != nil { return nil, err } return component.CurrentPrice, nil } // CalculatePrice calculates price using the specified method func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) { if periodDays == 0 { periodDays = s.config.DefaultPeriodDays } points, err := s.priceRepo.GetPriceHistory(lotName, periodDays) if err != nil { return 0, err } if len(points) == 0 { return 0, nil } prices := make([]float64, len(points)) for i, p := range points { prices[i] = p.Price } switch method { case models.PriceMethodAverage: return CalculateAverage(prices), nil case models.PriceMethodWeightedMedian: return CalculateWeightedMedian(points, periodDays), nil case models.PriceMethodMedian: fallthrough default: return CalculateMedian(prices), nil } } // UpdateComponentPrice recalculates and updates the price for a component func (s *Service) UpdateComponentPrice(lotName string) error { component, err := s.componentRepo.GetByLotName(lotName) if err != nil { return err } price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays) if err != nil { return err } now := time.Now() if price > 0 { component.CurrentPrice = &price component.PriceUpdatedAt = &now } return s.componentRepo.Update(component) } // SetManualPrice sets a manual price override func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error { override := &models.PriceOverride{ LotName: lotName, Price: price, ValidFrom: time.Now(), Reason: reason, CreatedBy: userID, } return s.priceRepo.CreatePriceOverride(override) } // UpdatePriceMethod changes the pricing method for a component func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error { component, err := s.componentRepo.GetByLotName(lotName) if err != nil { return err } component.PriceMethod = method if periodDays > 0 { component.PricePeriodDays = periodDays } if err := s.componentRepo.Update(component); err != nil { return err } return s.UpdateComponentPrice(lotName) } // GetPriceStats returns statistics for a component's price history func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) { if periodDays == 0 { periodDays = s.config.DefaultPeriodDays } points, err := s.priceRepo.GetPriceHistory(lotName, periodDays) if err != nil { return nil, err } if len(points) == 0 { return &PriceStats{QuoteCount: 0}, nil } prices := make([]float64, len(points)) for i, p := range points { prices[i] = p.Price } return &PriceStats{ QuoteCount: len(points), MinPrice: CalculatePercentile(prices, 0), MaxPrice: CalculatePercentile(prices, 100), MedianPrice: CalculateMedian(prices), AveragePrice: CalculateAverage(prices), StdDeviation: CalculateStdDev(prices), LatestPrice: points[0].Price, LatestDate: points[0].Date, OldestDate: points[len(points)-1].Date, Percentile25: CalculatePercentile(prices, 25), Percentile75: CalculatePercentile(prices, 75), }, nil } type PriceStats struct { QuoteCount int `json:"quote_count"` MinPrice float64 `json:"min_price"` MaxPrice float64 `json:"max_price"` MedianPrice float64 `json:"median_price"` AveragePrice float64 `json:"average_price"` StdDeviation float64 `json:"std_deviation"` LatestPrice float64 `json:"latest_price"` LatestDate time.Time `json:"latest_date"` OldestDate time.Time `json:"oldest_date"` Percentile25 float64 `json:"percentile_25"` Percentile75 float64 `json:"percentile_75"` } // RecalculateAllPrices recalculates prices for all components func (s *Service) RecalculateAllPrices() (updated int, errors int) { return s.RecalculateAllPricesWithProgress(nil) } // RecalculateAllPricesWithProgress recalculates prices and reports progress. func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) { if s.db == nil { return 0, 0 } // Logic mirrors "Обновить цены" in admin pricing. var components []models.LotMetadata if err := s.db.Find(&components).Error; err != nil { return 0, len(components) } total := len(components) var allLotNames []string _ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error type lotDate struct { Lot string Date time.Time } var latestDates []lotDate _ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error lotLatestDate := make(map[string]time.Time, len(latestDates)) for _, ld := range latestDates { lotLatestDate[ld.Lot] = ld.Date } var skipped, manual, unchanged int now := time.Now() current := 0 for _, comp := range components { current++ reportProgress := func() { if onProgress != nil && (current%10 == 0 || current == total) { onProgress(RecalculateProgress{ Current: current, Total: total, LotName: comp.LotName, Updated: updated, Errors: errors, }) } } if comp.ManualPrice != nil && *comp.ManualPrice > 0 { manual++ reportProgress() continue } method := comp.PriceMethod if method == "" { method = models.PriceMethodMedian } var sourceLots []string if comp.MetaPrices != "" { sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames) } else { sourceLots = []string{comp.LotName} } if len(sourceLots) == 0 { skipped++ reportProgress() continue } if comp.PriceUpdatedAt != nil { hasNewData := false for _, lot := range sourceLots { if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) { hasNewData = true break } } if !hasNewData { unchanged++ reportProgress() continue } } var prices []float64 if comp.PricePeriodDays > 0 { _ = s.db.Raw( `SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`, sourceLots, comp.PricePeriodDays, ).Pluck("price", &prices).Error } else { _ = s.db.Raw( `SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots, ).Pluck("price", &prices).Error } if len(prices) == 0 && comp.PricePeriodDays > 0 { _ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error } if len(prices) == 0 { skipped++ reportProgress() continue } var basePrice float64 switch method { case models.PriceMethodAverage: basePrice = CalculateAverage(prices) default: basePrice = CalculateMedian(prices) } if basePrice <= 0 { skipped++ reportProgress() continue } finalPrice := basePrice if comp.PriceCoefficient != 0 { finalPrice = finalPrice * (1 + comp.PriceCoefficient/100) } if err := s.db.Model(&models.LotMetadata{}). Where("lot_name = ?", comp.LotName). Updates(map[string]interface{}{ "current_price": finalPrice, "price_updated_at": now, }).Error; err != nil { errors++ } else { updated++ } reportProgress() } if onProgress != nil && total == 0 { onProgress(RecalculateProgress{ Current: 0, Total: 0, LotName: "", Updated: updated, Errors: errors, }) } return updated, errors } func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string { sources := strings.Split(metaPrices, ",") var result []string seen := make(map[string]bool) for _, source := range sources { source = strings.TrimSpace(source) if source == "" || source == excludeLot { continue } if strings.HasSuffix(source, "*") { prefix := strings.TrimSuffix(source, "*") for _, lot := range allLotNames { if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] { result = append(result, lot) seen[lot] = true } } } else if !seen[source] { result = append(result, source) seen[source] = true } } return result }