From 8e5c4f5a7ceeeea6aa92995d70684aec0af81768 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 6 Feb 2026 13:00:27 +0300 Subject: [PATCH] Use admin price-refresh logic for pricelist recalculation --- internal/repository/component.go | 4 + internal/services/pricing/service.go | 203 +++++++++++++++++++++++++-- 2 files changed, 192 insertions(+), 15 deletions(-) diff --git a/internal/repository/component.go b/internal/repository/component.go index b3e7817..1227c6a 100644 --- a/internal/repository/component.go +++ b/internal/repository/component.go @@ -110,6 +110,10 @@ func (r *ComponentRepository) Update(component *models.LotMetadata) error { return r.db.Save(component).Error } +func (r *ComponentRepository) DB() *gorm.DB { + return r.db +} + func (r *ComponentRepository) Create(component *models.LotMetadata) error { return r.db.Create(component).Error } diff --git a/internal/services/pricing/service.go b/internal/services/pricing/service.go index bd4b0e5..1530903 100644 --- a/internal/services/pricing/service.go +++ b/internal/services/pricing/service.go @@ -1,17 +1,28 @@ package pricing import ( + "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/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( @@ -19,10 +30,16 @@ func NewService( 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, } } @@ -179,27 +196,183 @@ type PriceStats struct { // RecalculateAllPrices recalculates prices for all components func (s *Service) RecalculateAllPrices() (updated int, errors int) { - // Get all components - filter := repository.ComponentFilter{} - offset := 0 - limit := 100 + return s.RecalculateAllPricesWithProgress(nil) +} - for { - components, _, err := s.componentRepo.List(filter, offset, limit) - if err != nil || len(components) == 0 { - break - } +// RecalculateAllPricesWithProgress recalculates prices and reports progress. +func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) { + if s.db == nil { + return 0, 0 + } - for _, comp := range components { - if err := s.UpdateComponentPrice(comp.LotName); err != nil { - errors++ - } else { - updated++ + // 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, + }) } } - offset += limit + 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 +}