package pricelist import ( "errors" "fmt" "log/slog" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/services/pricing" "gorm.io/gorm" ) type Service struct { repo *repository.PricelistRepository componentRepo *repository.ComponentRepository pricingSvc *pricing.Service db *gorm.DB } type CreateProgress struct { Current int Total int Status string Message string Updated int Errors int LotName string } func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service { return &Service{ repo: repo, componentRepo: componentRepo, pricingSvc: pricingSvc, db: db, } } // CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { return s.CreateFromCurrentPricesWithProgress(createdBy, nil) } // CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress. func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) { if s.repo == nil || s.db == nil { return nil, fmt.Errorf("offline mode: cannot create pricelists") } report := func(p CreateProgress) { if onProgress != nil { onProgress(p) } } report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"}) updated, errs := 0, 0 if s.pricingSvc != nil { report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"}) updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) { if p.Total <= 0 { return } phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0) if phaseCurrent > 91 { phaseCurrent = 91 } report(CreateProgress{ Current: phaseCurrent, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов", Updated: p.Updated, Errors: p.Errors, LotName: p.LotName, }) }) } report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs}) report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"}) expiresAt := time.Now().AddDate(1, 0, 0) // +1 year const maxCreateAttempts = 5 var pricelist *models.Pricelist for attempt := 1; attempt <= maxCreateAttempts; attempt++ { version, err := s.repo.GenerateVersion() if err != nil { return nil, fmt.Errorf("generating version: %w", err) } pricelist = &models.Pricelist{ Version: version, CreatedBy: createdBy, IsActive: true, ExpiresAt: &expiresAt, } if err := s.repo.Create(pricelist); err != nil { if isVersionConflictError(err) && attempt < maxCreateAttempts { slog.Warn("pricelist version conflict, retrying", "attempt", attempt, "version", version, "error", err, ) time.Sleep(time.Duration(attempt*25) * time.Millisecond) continue } return nil, fmt.Errorf("creating pricelist: %w", err) } break } // Get all components with prices from qt_lot_metadata var metadata []models.LotMetadata if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil { return nil, fmt.Errorf("getting lot metadata: %w", err) } // Create pricelist items with all price settings items := make([]models.PricelistItem, 0, len(metadata)) for _, m := range metadata { if m.CurrentPrice == nil || *m.CurrentPrice <= 0 { continue } items = append(items, models.PricelistItem{ PricelistID: pricelist.ID, LotName: m.LotName, Price: *m.CurrentPrice, PriceMethod: string(m.PriceMethod), PricePeriodDays: m.PricePeriodDays, PriceCoefficient: m.PriceCoefficient, ManualPrice: m.ManualPrice, MetaPrices: m.MetaPrices, }) } if err := s.repo.CreateItems(items); err != nil { // Clean up the pricelist if items creation fails s.repo.Delete(pricelist.ID) return nil, fmt.Errorf("creating pricelist items: %w", err) } pricelist.ItemCount = len(items) slog.Info("pricelist created", "id", pricelist.ID, "version", pricelist.Version, "items", len(items), "created_by", createdBy, ) report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs}) return pricelist, nil } func isVersionConflictError(err error) bool { if errors.Is(err, gorm.ErrDuplicatedKey) { return true } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version") } // List returns pricelists with pagination func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { // If no database connection (offline mode), return empty list if s.repo == nil { return []models.PricelistSummary{}, 0, nil } if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } offset := (page - 1) * perPage return s.repo.List(offset, perPage) } // ListActive returns active pricelists with pagination. func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) { if s.repo == nil { return []models.PricelistSummary{}, 0, nil } if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } offset := (page - 1) * perPage return s.repo.ListActive(offset, perPage) } // GetByID returns a pricelist by ID func (s *Service) GetByID(id uint) (*models.Pricelist, error) { if s.repo == nil { return nil, fmt.Errorf("offline mode: pricelist service not available") } return s.repo.GetByID(id) } // GetItems returns pricelist items with pagination func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) { if s.repo == nil { return []models.PricelistItem{}, 0, nil } if page < 1 { page = 1 } if perPage < 1 { perPage = 50 } offset := (page - 1) * perPage return s.repo.GetItems(pricelistID, offset, perPage, search) } // Delete deletes a pricelist by ID func (s *Service) Delete(id uint) error { if s.repo == nil { return fmt.Errorf("offline mode: cannot delete pricelists") } return s.repo.Delete(id) } // SetActive toggles active state for a pricelist. func (s *Service) SetActive(id uint, isActive bool) error { if s.repo == nil { return fmt.Errorf("offline mode: cannot update pricelists") } return s.repo.SetActive(id, isActive) } // GetPriceForLot returns price by pricelist/lot. func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) { if s.repo == nil { return 0, fmt.Errorf("offline mode: pricelist service not available") } return s.repo.GetPriceForLot(pricelistID, lotName) } // CanWrite returns true if the user can create pricelists func (s *Service) CanWrite() bool { if s.repo == nil { return false } return s.repo.CanWrite() } // CanWriteDebug returns write permission status with debug info func (s *Service) CanWriteDebug() (bool, string) { if s.repo == nil { return false, "offline mode" } return s.repo.CanWriteDebug() } // GetLatestActive returns the most recent active pricelist func (s *Service) GetLatestActive() (*models.Pricelist, error) { if s.repo == nil { return nil, fmt.Errorf("offline mode: pricelist service not available") } return s.repo.GetLatestActive() } // CleanupExpired deletes expired and unused pricelists func (s *Service) CleanupExpired() (int, error) { if s.repo == nil { return 0, fmt.Errorf("offline mode: cleanup not available") } expired, err := s.repo.GetExpiredUnused() if err != nil { return 0, err } deleted := 0 for _, pl := range expired { if err := s.repo.Delete(pl.ID); err != nil { slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err) continue } deleted++ } slog.Info("cleaned up expired pricelists", "deleted", deleted) return deleted, nil }