From 38d7332a382806ee8a6b2a3f83a63f8a4c01c7fe Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 6 Feb 2026 10:14:24 +0300 Subject: [PATCH] Update pricelist repository, service, and tests --- internal/repository/pricelist.go | 31 ++++++++++--- internal/repository/pricelist_test.go | 64 ++++++++++++++++++++++++++ internal/services/pricelist/service.go | 49 ++++++++++++++------ 3 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 internal/repository/pricelist_test.go diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 806e764..f411209 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -1,7 +1,9 @@ package repository import ( + "errors" "fmt" + "strconv" "strings" "time" @@ -210,14 +212,31 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear func (r *PricelistRepository) GenerateVersion() (string, error) { today := time.Now().Format("2006-01-02") - var count int64 - if err := r.db.Model(&models.Pricelist{}). - Where("version LIKE ?", today+"%"). - Count(&count).Error; err != nil { - return "", fmt.Errorf("counting today's pricelists: %w", err) + var last models.Pricelist + err := r.db.Model(&models.Pricelist{}). + Select("version"). + Where("version LIKE ?", today+"-%"). + Order("version DESC"). + Limit(1). + Take(&last).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Sprintf("%s-001", today), nil + } + return "", fmt.Errorf("loading latest today's pricelist version: %w", err) } - return fmt.Sprintf("%s-%03d", today, count+1), nil + parts := strings.Split(last.Version, "-") + if len(parts) < 4 { + return "", fmt.Errorf("invalid pricelist version format: %s", last.Version) + } + + n, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return "", fmt.Errorf("parsing pricelist sequence %q: %w", parts[len(parts)-1], err) + } + + return fmt.Sprintf("%s-%03d", today, n+1), nil } // CanWrite checks if the current database user has INSERT permission on qt_pricelists diff --git a/internal/repository/pricelist_test.go b/internal/repository/pricelist_test.go new file mode 100644 index 0000000..fc83d82 --- /dev/null +++ b/internal/repository/pricelist_test.go @@ -0,0 +1,64 @@ +package repository + +import ( + "fmt" + "testing" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/models" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestGenerateVersion_FirstOfDay(t *testing.T) { + repo := newTestPricelistRepository(t) + + version, err := repo.GenerateVersion() + if err != nil { + t.Fatalf("GenerateVersion returned error: %v", err) + } + + today := time.Now().Format("2006-01-02") + want := fmt.Sprintf("%s-001", today) + if version != want { + t.Fatalf("expected %s, got %s", want, version) + } +} + +func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) { + repo := newTestPricelistRepository(t) + today := time.Now().Format("2006-01-02") + + seed := []models.Pricelist{ + {Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true}, + {Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true}, + } + for _, pl := range seed { + if err := repo.Create(&pl); err != nil { + t.Fatalf("seed insert failed: %v", err) + } + } + + version, err := repo.GenerateVersion() + if err != nil { + t.Fatalf("GenerateVersion returned error: %v", err) + } + + want := fmt.Sprintf("%s-004", today) + if version != want { + t.Fatalf("expected %s, got %s", want, version) + } +} + +func newTestPricelistRepository(t *testing.T) *PricelistRepository { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.Pricelist{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return NewPricelistRepository(db) +} diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index 7af325b..39df7bf 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -1,8 +1,10 @@ package pricelist import ( + "errors" "fmt" "log/slog" + "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -30,22 +32,35 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, return nil, fmt.Errorf("offline mode: cannot create pricelists") } - version, err := s.repo.GenerateVersion() - if err != nil { - return nil, fmt.Errorf("generating version: %w", err) - } - 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, - } + pricelist = &models.Pricelist{ + Version: version, + CreatedBy: createdBy, + IsActive: true, + ExpiresAt: &expiresAt, + } - if err := s.repo.Create(pricelist); err != nil { - return nil, fmt.Errorf("creating pricelist: %w", err) + 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 @@ -90,6 +105,14 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, 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