Optimize task retention from 5 minutes to 30 seconds to reduce polling overhead since toast notifications are shown only once. Add conditional warehouse pricelist creation via checkbox. Fix category storage in warehouse pricelists to properly load from lot table. Replace SSE with task polling for all long operations. Add comprehensive logging for debugging while minimizing noise from polling endpoints. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
445 lines
14 KiB
Go
445 lines
14 KiB
Go
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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
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 {
|
|
categoryMap[lot.LotName] = lot.LotCategory
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
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 {
|
|
categoryMap[lot.LotName] = lot.LotCategory
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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)
|
|
}
|