package pricelist import ( "errors" "fmt" "log/slog" "strings" "time" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/repository" "git.mchus.pro/mchus/priceforge/internal/services/pricing" "git.mchus.pro/mchus/priceforge/internal/warehouse" "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 } type CreateItemInput struct { LotName string Price float64 PriceMethod string Category 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.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate)) } // CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source. func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) { return s.CreateForSourceWithProgress(createdBy, source, nil, nil) } // CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress. func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) { return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress) } // CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list. func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) { if s.repo == nil || s.db == nil { return nil, fmt.Errorf("offline mode: cannot create pricelists") } source = string(models.NormalizePricelistSource(source)) report := func(p CreateProgress) { if onProgress != nil { onProgress(p) } } report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"}) updated, errs := 0, 0 if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil { report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"}) lastReportedPercent := 1 updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) { if p.Total <= 0 { return } // Detect retry phase (when current > total means we're in retry) isRetry := p.Current > p.Total var phaseCurrent int var message string if isRetry { // Retry phase: map to 85-90% retryProgress := float64(p.Current-p.Total) / float64(p.Current-p.Total+1) phaseCurrent = 85 + int(retryProgress*5.0) if phaseCurrent > 90 { phaseCurrent = 90 } message = "Повтор для пропущенных компонентов" } else { // Normal phase: map to 1-85% phaseCurrent = 1 + int(float64(p.Current)/float64(p.Total)*84.0) if phaseCurrent > 85 { phaseCurrent = 85 } message = "Обновление цен компонентов" } // Only send SSE event if percentage changed by at least 5% to prevent connection overload if phaseCurrent-lastReportedPercent >= 5 || phaseCurrent == 85 || isRetry { lastReportedPercent = phaseCurrent report(CreateProgress{ Current: phaseCurrent, Total: 100, Status: "recalculating", Message: message, Updated: p.Updated, Errors: p.Errors, LotName: p.LotName, }) } }) } report(CreateProgress{Current: 91, 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.GenerateVersionBySource(source) if err != nil { return nil, fmt.Errorf("generating version: %w", err) } pricelist = &models.Pricelist{ Source: source, 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 } items := make([]models.PricelistItem, 0) if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) { warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db) if err != nil { _ = s.repo.Delete(pricelist.ID) return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err) } sourceItems = make([]CreateItemInput, 0, len(warehouseItems)) for _, item := range warehouseItems { sourceItems = append(sourceItems, CreateItemInput{ LotName: item.LotName, Price: item.Price, PriceMethod: item.PriceMethod, Category: item.Category, }) } } if len(sourceItems) > 0 { // For warehouse and other explicit source items - use only provided data // DO NOT load metadata settings (price_period_days, coefficient, etc.) // Load categories from lot table lotNames := make([]string, 0, len(sourceItems)) for _, srcItem := range sourceItems { if strings.TrimSpace(srcItem.LotName) != "" { lotNames = append(lotNames, strings.TrimSpace(srcItem.LotName)) } } categoryMap := make(map[string]*string) missingCategoryLots := make([]string, 0) defaultCategory := models.DefaultLotCategoryCode if len(lotNames) > 0 { var lots []models.Lot if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil { for _, lot := range lots { if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" { categoryMap[lot.LotName] = &defaultCategory missingCategoryLots = append(missingCategoryLots, lot.LotName) continue } categoryMap[lot.LotName] = lot.LotCategory } } } if len(missingCategoryLots) > 0 { ensureCategoryExists(s.db, defaultCategory) _ = s.db.Model(&models.Lot{}). Where("lot_name IN ?", missingCategoryLots). Update("lot_category", defaultCategory).Error } for _, lotName := range lotNames { if _, ok := categoryMap[lotName]; !ok { categoryMap[lotName] = &defaultCategory } } items = make([]models.PricelistItem, 0, len(sourceItems)) for _, srcItem := range sourceItems { lotName := strings.TrimSpace(srcItem.LotName) if lotName == "" || srcItem.Price <= 0 { continue } items = append(items, models.PricelistItem{ PricelistID: pricelist.ID, LotName: lotName, LotCategory: categoryMap[lotName], Price: srcItem.Price, PriceMethod: strings.TrimSpace(srcItem.PriceMethod), }) } } else { // Default snapshot source for estimate and backward compatibility. type LotMetadataWithCategory struct { models.LotMetadata LotCategory string } var metadata []LotMetadataWithCategory if err := s.db.Table("qt_lot_metadata as m"). Select("m.*, COALESCE(l.lot_category, '') as lot_category"). Joins("LEFT JOIN lot as l ON l.lot_name = m.lot_name"). Where("m.current_price IS NOT NULL AND m.current_price > 0 AND m.is_hidden = 0"). Scan(&metadata).Error; err != nil { return nil, fmt.Errorf("getting lot metadata with categories: %w", err) } // Load categories from lot table for all metadata items lotNames := make([]string, 0, len(metadata)) for _, m := range metadata { lotNames = append(lotNames, m.LotName) } categoryMap := make(map[string]*string) missingCategoryLots := make([]string, 0) defaultCategory := models.DefaultLotCategoryCode if len(lotNames) > 0 { var lots []models.Lot if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil { for _, lot := range lots { if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" { categoryMap[lot.LotName] = &defaultCategory missingCategoryLots = append(missingCategoryLots, lot.LotName) continue } categoryMap[lot.LotName] = lot.LotCategory } } } if len(missingCategoryLots) > 0 { ensureCategoryExists(s.db, defaultCategory) _ = s.db.Model(&models.Lot{}). Where("lot_name IN ?", missingCategoryLots). Update("lot_category", defaultCategory).Error } for _, lotName := range lotNames { if _, ok := categoryMap[lotName]; !ok { categoryMap[lotName] = &defaultCategory } } // 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, LotCategory: categoryMap[m.LotName], Price: *m.CurrentPrice, PriceMethod: string(m.PriceMethod), PricePeriodDays: m.PricePeriodDays, PriceCoefficient: m.PriceCoefficient, ManualPrice: m.ManualPrice, MetaPrices: m.MetaPrices, }) } } if len(items) == 0 { _ = s.repo.Delete(pricelist.ID) return nil, fmt.Errorf("cannot create empty pricelist for source %q", source) } 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 ensureCategoryExists(db *gorm.DB, code string) { var count int64 if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 { return } var maxOrder int if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil { return } _ = db.Create(&models.Category{ Code: code, Name: code, NameRu: code, DisplayOrder: maxOrder + 1, }).Error } 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_source_version") || strings.Contains(msg, "idx_qt_pricelists_version")) } // List returns pricelists with pagination func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { return s.ListBySource(page, perPage, "") } // ListBySource returns pricelists with optional source filter. func (s *Service) ListBySource(page, perPage int, source string) ([]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.ListBySource(source, offset, perPage) } // ListActive returns active pricelists with pagination. func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) { return s.ListActiveBySource(page, perPage, "") } // ListActiveBySource returns active pricelists with optional source filter. func (s *Service) ListActiveBySource(page, perPage int, source string) ([]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.ListActiveBySource(source, 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) } func (s *Service) GetLotNames(pricelistID uint) ([]string, error) { if s.repo == nil { return []string{}, nil } return s.repo.GetLotNames(pricelistID) } // 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) { return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate)) } // GetLatestActiveBySource returns the latest active pricelist for a source. func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) { if s.repo == nil { return nil, fmt.Errorf("offline mode: pricelist service not available") } return s.repo.GetLatestActiveBySource(source) } // 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 } // StreamItemsForExport streams pricelist items in batches for efficient CSV export func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error { if s.repo == nil { return fmt.Errorf("offline mode: cannot stream pricelist items") } return s.repo.StreamItemsForExport(pricelistID, batchSize, callback) }