Apply remaining pricelist and local-first updates

This commit is contained in:
Mikhail Chusavitin
2026-02-06 13:01:40 +03:00
parent 8e5c4f5a7c
commit 80ec7bc6b8
19 changed files with 767 additions and 30 deletions

View File

@@ -479,7 +479,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService) quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
exportService = services.NewExportService(cfg.Export, categoryRepo) exportService = services.NewExportService(cfg.Export, categoryRepo)
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo) pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
} else { } else {
// In offline mode, we still need to create services that don't require DB // In offline mode, we still need to create services that don't require DB
pricingService = pricing.NewService(nil, nil, cfg.Pricing) pricingService = pricing.NewService(nil, nil, cfg.Pricing)
@@ -487,7 +487,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteService = services.NewQuoteService(nil, nil, pricingService) quoteService = services.NewQuoteService(nil, nil, pricingService)
exportService = services.NewExportService(cfg.Export, nil) exportService = services.NewExportService(cfg.Export, nil)
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing) alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
pricelistService = pricelist.NewService(nil, nil, nil) pricelistService = pricelist.NewService(nil, nil, nil, nil)
} }
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -740,6 +740,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.POST("", pricelistHandler.Create) pricelists.POST("", pricelistHandler.Create)
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
pricelists.DELETE("/:id", pricelistHandler.Delete) pricelists.DELETE("/:id", pricelistHandler.Delete)
} }

View File

@@ -1,12 +1,13 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin"
) )
type PricelistHandler struct { type PricelistHandler struct {
@@ -22,8 +23,19 @@ func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *
func (h *PricelistHandler) List(c *gin.Context) { func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
activeOnly := c.DefaultQuery("active_only", "false") == "true"
pricelists, total, err := h.service.List(page, perPage) var (
pricelists any
total int64
err error
)
if activeOnly {
pricelists, total, err = h.service.ListActive(page, perPage)
} else {
pricelists, total, err = h.service.List(page, perPage)
}
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -111,6 +123,74 @@ func (h *PricelistHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, pl) c.JSON(http.StatusCreated, pl)
} }
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
pl, err := h.service.CreateFromCurrentPrices(createdBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
return
}
sendProgress := func(payload gin.H) {
c.SSEvent("progress", payload)
flusher.Flush()
}
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) {
sendProgress(gin.H{
"current": p.Current,
"total": p.Total,
"status": p.Status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
"lot_name": p.LotName,
})
})
if err != nil {
sendProgress(gin.H{
"current": 0,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Ошибка: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
})
}
// Delete deletes a pricelist by ID // Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) { func (h *PricelistHandler) Delete(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug() canWrite, debugInfo := h.service.CanWriteDebug()
@@ -137,6 +217,40 @@ func (h *PricelistHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"}) c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
} }
// SetActive toggles active flag on a pricelist.
func (h *PricelistHandler) SetActive(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination // GetItems returns items for a pricelist with pagination
func (h *PricelistHandler) GetItems(c *gin.Context) { func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")

View File

@@ -28,6 +28,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes, Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate, IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount, ServerCount: cfg.ServerCount,
PricelistID: cfg.PricelistID,
PriceUpdatedAt: cfg.PriceUpdatedAt, PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
@@ -70,6 +71,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes, Notes: local.Notes,
IsTemplate: local.IsTemplate, IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount, ServerCount: local.ServerCount,
PricelistID: local.PricelistID,
PriceUpdatedAt: local.PriceUpdatedAt, PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,
} }

View File

@@ -530,6 +530,15 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
return &pricelist, nil return &pricelist, nil
} }
// GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID // GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) { func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
@@ -605,6 +614,25 @@ func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
Update("is_used", isUsed).Error Update("is_used", isUsed).Error
} }
// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error
})
}
// DeleteLocalPricelist deletes a pricelist and its items // DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error { func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete items first // Delete items first

View File

@@ -42,6 +42,11 @@ var localMigrations = []localMigration{
name: "Create default projects and attach existing configurations", name: "Create default projects and attach existing configurations",
run: backfillProjectsForConfigurations, run: backfillProjectsForConfigurations,
}, },
{
id: "2026_02_06_pricelist_backfill",
name: "Attach existing configurations to latest local pricelist and recalc usage",
run: backfillConfigurationPricelists,
},
} }
func runLocalMigrations(db *gorm.DB) error { func runLocalMigrations(db *gorm.DB) error {
@@ -192,6 +197,40 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
return &project, nil return &project, nil
} }
func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load latest local pricelist: %w", err)
}
if err := tx.Model(&LocalConfiguration{}).
Where("pricelist_id IS NULL").
Update("pricelist_id", latest.ServerID).Error; err != nil {
return fmt.Errorf("backfill configuration pricelist_id: %w", err)
}
if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return fmt.Errorf("reset local pricelist usage flags: %w", err)
}
if err := tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error; err != nil {
return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
}
return nil
}
func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time { func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
if candidate.IsZero() { if candidate.IsZero() {
return fallback return fallback

View File

@@ -72,6 +72,7 @@ type LocalConfiguration struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@@ -22,6 +22,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes, "notes": localCfg.Notes,
"is_template": localCfg.IsTemplate, "is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount, "server_count": localCfg.ServerCount,
"pricelist_id": localCfg.PricelistID,
"price_updated_at": localCfg.PriceUpdatedAt, "price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt, "created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt, "updated_at": localCfg.UpdatedAt,
@@ -50,6 +51,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id"`
PriceUpdatedAt *time.Time `json:"price_updated_at"` PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"` OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"` OriginalUsername string `json:"original_username"`
@@ -74,6 +76,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes, Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate, IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount, ServerCount: snapshot.ServerCount,
PricelistID: snapshot.PricelistID,
PriceUpdatedAt: snapshot.PriceUpdatedAt, PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID, OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername, OriginalUsername: snapshot.OriginalUsername,

View File

@@ -53,6 +53,7 @@ type Configuration struct {
Notes string `gorm:"type:text" json:"notes"` Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@@ -64,6 +64,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
for i, pl := range pricelists { for i, pl := range pricelists {
var itemCount int64 var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount) r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
usageCount, _ := r.CountUsage(pl.ID)
summaries[i] = models.PricelistSummary{ summaries[i] = models.PricelistSummary{
ID: pl.ID, ID: pl.ID,
@@ -72,7 +73,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
CreatedBy: pl.CreatedBy, CreatedBy: pl.CreatedBy,
IsActive: pl.IsActive, IsActive: pl.IsActive,
UsageCount: pl.UsageCount, UsageCount: int(usageCount),
ExpiresAt: pl.ExpiresAt, ExpiresAt: pl.ExpiresAt,
ItemCount: itemCount, ItemCount: itemCount,
} }
@@ -92,6 +93,9 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
var itemCount int64 var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount) r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
pricelist.ItemCount = int(itemCount) pricelist.ItemCount = int(itemCount)
if usageCount, err := r.CountUsage(id); err == nil {
pricelist.UsageCount = int(usageCount)
}
return &pricelist, nil return &pricelist, nil
} }
@@ -132,13 +136,13 @@ func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
// Delete deletes a pricelist if usage_count is 0 // Delete deletes a pricelist if usage_count is 0
func (r *PricelistRepository) Delete(id uint) error { func (r *PricelistRepository) Delete(id uint) error {
pricelist, err := r.GetByID(id) usageCount, err := r.CountUsage(id)
if err != nil { if err != nil {
return err return err
} }
if pricelist.UsageCount > 0 { if usageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount) return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", usageCount)
} }
// Delete items first // Delete items first
@@ -208,6 +212,20 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
return items, total, nil return items, total, nil
} }
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem
if err := r.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).First(&item).Error; err != nil {
return 0, err
}
return item.Price, nil
}
// SetActive toggles active flag on a pricelist.
func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).Update("is_active", isActive).Error
}
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN // GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
func (r *PricelistRepository) GenerateVersion() (string, error) { func (r *PricelistRepository) GenerateVersion() (string, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
@@ -295,6 +313,15 @@ func (r *PricelistRepository) DecrementUsageCount(id uint) error {
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
} }
// CountUsage returns number of configurations referencing pricelist.
func (r *PricelistRepository) CountUsage(id uint) (int64, error) {
var count int64
if err := r.db.Table("qt_configurations").Where("pricelist_id = ?", id).Count(&count).Error; err != nil {
return 0, fmt.Errorf("counting configurations for pricelist %d: %w", id, err)
}
return count, nil
}
// GetExpiredUnused returns pricelists that are expired and unused // GetExpiredUnused returns pricelists that are expired and unused
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) { func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
var pricelists []models.Pricelist var pricelists []models.Pricelist

View File

@@ -24,6 +24,7 @@ type ConfigurationService struct {
configRepo *repository.ConfigurationRepository configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository
quoteService *QuoteService quoteService *QuoteService
} }
@@ -31,12 +32,14 @@ func NewConfigurationService(
configRepo *repository.ConfigurationRepository, configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository, projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository,
quoteService *QuoteService, quoteService *QuoteService,
) *ConfigurationService { ) *ConfigurationService {
return &ConfigurationService{ return &ConfigurationService{
configRepo: configRepo, configRepo: configRepo,
projectRepo: projectRepo, projectRepo: projectRepo,
componentRepo: componentRepo, componentRepo: componentRepo,
pricelistRepo: pricelistRepo,
quoteService: quoteService, quoteService: quoteService,
} }
} }
@@ -49,6 +52,7 @@ type CreateConfigRequest struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
PricelistID *uint `json:"pricelist_id,omitempty"`
} }
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) { func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
@@ -56,6 +60,10 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
@@ -75,6 +83,7 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
PricelistID: pricelistID,
} }
if err := s.configRepo.Create(config); err != nil { if err := s.configRepo.Create(config); err != nil {
@@ -115,6 +124,10 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
@@ -131,6 +144,7 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.Notes = req.Notes config.Notes = req.Notes
config.IsTemplate = req.IsTemplate config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
return nil, err return nil, err
@@ -207,6 +221,7 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, // Clone is never a template IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
} }
if err := s.configRepo.Create(clone); err != nil { if err := s.configRepo.Create(clone); err != nil {
@@ -261,6 +276,10 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
@@ -275,6 +294,7 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.Notes = req.Notes config.Notes = req.Notes
config.IsTemplate = req.IsTemplate config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount config.ServerCount = req.ServerCount
config.PricelistID = pricelistID
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
return nil, err return nil, err
@@ -341,6 +361,7 @@ func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName s
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
} }
if err := s.configRepo.Create(clone); err != nil { if err := s.configRepo.Create(clone); err != nil {
@@ -370,6 +391,23 @@ func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectU
return &project.UUID, nil return &project.UUID, nil
} }
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if s.pricelistRepo == nil {
return pricelistID, nil
}
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
return nil, err
}
return pricelistID, nil
}
latest, err := s.pricelistRepo.GetLatestActive()
if err != nil {
return nil, nil
}
return &latest.ID, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check // RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid) config, err := s.configRepo.GetByUUID(uuid)
@@ -377,8 +415,30 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
updatedItems := make(models.ConfigItems, len(config.Items)) updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items { for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName) metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil { if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item updatedItems[i] = item
@@ -399,6 +459,9 @@ func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configu
} }
config.TotalPrice = &total config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
now := time.Now() now := time.Now()
config.PriceUpdatedAt = &now config.PriceUpdatedAt = &now
@@ -432,10 +495,32 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
// Update prices for all items // Update prices for all items
updatedItems := make(models.ConfigItems, len(config.Items)) updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items { for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Get current component price // Get current component price
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName) metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil { if err != nil || metadata.CurrentPrice == nil {
// Keep original item if component not found or no price available // Keep original item if component not found or no price available
@@ -461,6 +546,9 @@ func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string)
} }
config.TotalPrice = &total config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
// Set price update timestamp // Set price update timestamp
now := time.Now() now := time.Now()

View File

@@ -59,6 +59,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
@@ -76,6 +80,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
PricelistID: pricelistID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -128,6 +133,10 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
@@ -150,6 +159,7 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -254,6 +264,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -324,10 +335,28 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
// Refresh local pricelists when online and use latest active/local pricelist for recalculation.
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
// Get current component price from local cache if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName) component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil { if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available // Keep original item if component not found or no price available
@@ -353,6 +382,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
now := time.Now() now := time.Now()
@@ -390,6 +422,10 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
if err != nil { if err != nil {
return nil, err return nil, err
} }
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total() total := req.Items.Total()
if req.ServerCount > 1 { if req.ServerCount > 1 {
@@ -411,6 +447,7 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.Notes = req.Notes localCfg.Notes = req.Notes
localCfg.IsTemplate = req.IsTemplate localCfg.IsTemplate = req.IsTemplate
localCfg.ServerCount = req.ServerCount localCfg.ServerCount = req.ServerCount
localCfg.PricelistID = pricelistID
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -502,6 +539,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newN
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@@ -640,10 +678,27 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if s.isOnline() {
_ = s.syncService.SyncPricelistsIfNeeded()
}
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
// Update prices for all items // Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items { for i, item := range localCfg.Items {
// Get current component price from local cache if latestErr == nil && latestPricelist != nil {
price, err := s.localDB.GetLocalPriceForLot(latestPricelist.ID, item.LotName)
if err == nil && price > 0 {
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Fallback to current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName) component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil { if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available // Keep original item if component not found or no price available
@@ -669,6 +724,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
} }
localCfg.TotalPrice = &total localCfg.TotalPrice = &total
if latestErr == nil && latestPricelist != nil {
localCfg.PricelistID = &latestPricelist.ServerID
}
// Set price update timestamp and mark for sync // Set price update timestamp and mark for sync
now := time.Now() now := time.Now()
@@ -815,6 +873,9 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err) return fmt.Errorf("enqueue create pending change: %w", err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -854,6 +915,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change: %w", operation, err) return fmt.Errorf("enqueue %s pending change: %w", operation, err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -958,6 +1022,7 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now() current.UpdatedAt = time.Now()
current.SyncStatus = "pending" current.SyncStatus = "pending"
@@ -1015,6 +1080,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil { if err := s.enqueueConfigurationPendingChangeTx(tx, &current, "rollback", version, userID); err != nil {
return fmt.Errorf("enqueue rollback pending change: %w", err) return fmt.Errorf("enqueue rollback pending change: %w", err)
} }
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil return nil
}) })
@@ -1038,6 +1106,7 @@ func (s *LocalConfigurationService) enqueueConfigurationPendingChangeTx(
IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation), IdempotencyKey: fmt.Sprintf("%s:v%d:%s", localCfg.UUID, version.VersionNo, operation),
ConfigurationUUID: localCfg.UUID, ConfigurationUUID: localCfg.UUID,
ProjectUUID: localCfg.ProjectUUID, ProjectUUID: localCfg.ProjectUUID,
PricelistID: localCfg.PricelistID,
Operation: operation, Operation: operation,
CurrentVersionID: version.ID, CurrentVersionID: version.ID,
CurrentVersionNo: version.VersionNo, CurrentVersionNo: version.VersionNo,
@@ -1071,6 +1140,21 @@ func (s *LocalConfigurationService) decodeConfigurationSnapshot(data string) (*l
return localdb.DecodeConfigurationSnapshot(data) return localdb.DecodeConfigurationSnapshot(data)
} }
func (s *LocalConfigurationService) recalculateLocalPricelistUsageTx(tx *gorm.DB) error {
if err := tx.Model(&localdb.LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE local_pricelists
SET is_used = 1
WHERE server_id IN (
SELECT DISTINCT pricelist_id
FROM local_configurations
WHERE pricelist_id IS NOT NULL AND is_active = 1
)
`).Error
}
func stringPtrOrNil(value string) *string { func stringPtrOrNil(value string) *string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { if trimmed == "" {
@@ -1116,3 +1200,25 @@ func (s *LocalConfigurationService) resolveProjectUUID(ownerUsername string, pro
return &project.UUID, nil return &project.UUID, nil
} }
func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
if s.isOnline() {
if _, err := s.syncService.SyncPricelists(); err == nil {
if _, err := s.localDB.GetLocalPricelistByServerID(*pricelistID); err == nil {
return pricelistID, nil
}
}
}
return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID)
}
latest, err := s.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, nil
}
return &latest.ServerID, nil
}

View File

@@ -9,29 +9,79 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/gorm" "gorm.io/gorm"
) )
type Service struct { type Service struct {
repo *repository.PricelistRepository repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
pricingSvc *pricing.Service
db *gorm.DB db *gorm.DB
} }
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service { 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{ return &Service{
repo: repo, repo: repo,
componentRepo: componentRepo, componentRepo: componentRepo,
pricingSvc: pricingSvc,
db: db, db: db,
} }
} }
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices // CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { 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 { if s.repo == nil || s.db == nil {
return nil, fmt.Errorf("offline mode: cannot create pricelists") 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 expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
const maxCreateAttempts = 5 const maxCreateAttempts = 5
var pricelist *models.Pricelist var pricelist *models.Pricelist
@@ -101,6 +151,7 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
"items", len(items), "items", len(items),
"created_by", createdBy, "created_by", createdBy,
) )
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
return pricelist, nil return pricelist, nil
} }
@@ -130,6 +181,21 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
return s.repo.List(offset, 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 // GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) { func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil { if s.repo == nil {
@@ -161,6 +227,22 @@ func (s *Service) Delete(id uint) error {
return s.repo.Delete(id) 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 // CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool { func (s *Service) CanWrite() bool {
if s.repo == nil { if s.repo == nil {

View File

@@ -63,6 +63,7 @@ type ConfigurationChangePayload struct {
IdempotencyKey string `json:"idempotency_key"` IdempotencyKey string `json:"idempotency_key"`
ConfigurationUUID string `json:"configuration_uuid"` ConfigurationUUID string `json:"configuration_uuid"`
ProjectUUID *string `json:"project_uuid,omitempty"` ProjectUUID *string `json:"project_uuid,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"`
Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete Operation string `json:"operation"` // create/update/rollback/deactivate/reactivate/delete
CurrentVersionID string `json:"current_version_id,omitempty"` CurrentVersionID string `json:"current_version_id,omitempty"`
CurrentVersionNo int `json:"current_version_no,omitempty"` CurrentVersionNo int `json:"current_version_no,omitempty"`
@@ -610,6 +611,9 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil { if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err) return fmt.Errorf("resolve configuration project: %w", err)
} }
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration pricelist: %w", err)
}
// Create on server // Create on server
if err := configRepo.Create(&cfg); err != nil { if err := configRepo.Create(&cfg); err != nil {
@@ -668,6 +672,9 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil { if err := s.ensureConfigurationProject(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration project: %w", err) return fmt.Errorf("resolve configuration project: %w", err)
} }
if err := s.ensureConfigurationPricelist(mariaDB, &cfg); err != nil {
return fmt.Errorf("resolve configuration pricelist: %w", err)
}
// Ensure we have a server ID before updating // Ensure we have a server ID before updating
// If the payload doesn't have ID, get it from local configuration // If the payload doesn't have ID, get it from local configuration
@@ -801,6 +808,29 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
return nil return nil
} }
func (s *Service) ensureConfigurationPricelist(mariaDB *gorm.DB, cfg *models.Configuration) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
}
pricelistRepo := repository.NewPricelistRepository(mariaDB)
if cfg.PricelistID != nil && *cfg.PricelistID > 0 {
if _, err := pricelistRepo.GetByID(*cfg.PricelistID); err == nil {
return nil
}
}
latest, err := pricelistRepo.GetLatestActive()
if err != nil {
cfg.PricelistID = nil
return nil
}
cfg.PricelistID = &latest.ID
return nil
}
func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error { func (s *Service) pushConfigurationRollback(change *localdb.PendingChange) error {
// Last-write-wins for now: rollback is pushed as an update with rollback metadata. // Last-write-wins for now: rollback is pushed as an update with rollback metadata.
return s.pushConfigurationUpdate(change) return s.pushConfigurationUpdate(change)
@@ -848,6 +878,7 @@ func (s *Service) resolveConfigurationPayloadForPush(change *localdb.PendingChan
if currentVersionNo > 0 { if currentVersionNo > 0 {
payload.CurrentVersionNo = currentVersionNo payload.CurrentVersionNo = currentVersionNo
} }
payload.PricelistID = currentCfg.PricelistID
} }
isStale := false isStale := false
@@ -885,6 +916,7 @@ func decodeConfigurationChangePayload(change *localdb.PendingChange) (Configurat
IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation), IdempotencyKey: fmt.Sprintf("%s:%s:legacy", cfg.UUID, change.Operation),
ConfigurationUUID: cfg.UUID, ConfigurationUUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID, ProjectUUID: cfg.ProjectUUID,
PricelistID: cfg.PricelistID,
Operation: change.Operation, Operation: change.Operation,
ConflictPolicy: "last_write_wins", ConflictPolicy: "last_write_wins",
Snapshot: cfg, Snapshot: cfg,

View File

@@ -248,6 +248,7 @@ CREATE TABLE qt_configurations (
notes TEXT NULL, notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0, is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1, server_count INTEGER NOT NULL DEFAULT 1,
pricelist_id INTEGER NULL,
price_updated_at DATETIME NULL, price_updated_at DATETIME NULL,
created_at DATETIME created_at DATETIME
);`).Error; err != nil { );`).Error; err != nil {

View File

@@ -0,0 +1,37 @@
-- Add pricelist binding to configurations
ALTER TABLE qt_configurations
ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count;
ALTER TABLE qt_configurations
ADD INDEX idx_qt_configurations_pricelist_id (pricelist_id),
ADD CONSTRAINT fk_qt_configurations_pricelist_id
FOREIGN KEY (pricelist_id)
REFERENCES qt_pricelists(id)
ON DELETE RESTRICT
ON UPDATE CASCADE;
-- Backfill existing configurations to latest active pricelist
SET @latest_active_pricelist_id := (
SELECT id
FROM qt_pricelists
WHERE is_active = 1
ORDER BY created_at DESC
LIMIT 1
);
UPDATE qt_configurations
SET pricelist_id = @latest_active_pricelist_id
WHERE pricelist_id IS NULL
AND @latest_active_pricelist_id IS NOT NULL;
-- Recalculate usage_count from configuration bindings
UPDATE qt_pricelists SET usage_count = 0;
UPDATE qt_pricelists pl
JOIN (
SELECT pricelist_id, COUNT(*) AS cnt
FROM qt_configurations
WHERE pricelist_id IS NOT NULL
GROUP BY pricelist_id
) cfg ON cfg.pricelist_id = pl.id
SET pl.usage_count = cfg.cnt;

View File

@@ -13,14 +13,14 @@
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button> <button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div> </div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"> <button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены Обновить цены
</button> </button>
</div> </div>
<!-- Progress bar --> <!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;"> <div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2"> <div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span> <span id="progress-text" class="font-medium">Обновление цен...</span>
<span id="progress-percent" class="font-bold">0%</span> <span id="progress-percent" class="font-bold">0%</span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-4"> <div class="w-full bg-gray-200 rounded-full h-4">
@@ -94,6 +94,16 @@
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span> Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p> </p>
<form id="pricelists-create-form" class="space-y-4"> <form id="pricelists-create-form" class="space-y-4">
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex justify-between items-center text-sm mb-2">
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()" <button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"> class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
@@ -750,11 +760,11 @@ function recalculateAll() {
// Show progress bar IMMEDIATELY // Show progress bar IMMEDIATELY
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Пересчёт...'; btn.textContent = 'Обновление...';
progressContainer.style.display = 'block'; progressContainer.style.display = 'block';
progressBar.style.width = '0%'; progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300'; progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...'; progressText.textContent = 'Запуск обновления...';
progressPercent.textContent = '0%'; progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...'; progressStats.textContent = 'Подготовка...';
@@ -769,7 +779,7 @@ function recalculateAll() {
reader.read().then(({done, value}) => { reader.read().then(({done, value}) => {
if (done) { if (done) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Пересчитать цены'; btn.textContent = 'Обновить цены';
progressText.textContent = 'Готово!'; progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full'; progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => { setTimeout(() => {
@@ -794,7 +804,7 @@ function recalculateAll() {
progressPercent.textContent = percent + '%'; progressPercent.textContent = percent + '%';
if (data.status === 'completed') { if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!'; progressText.textContent = 'Обновление завершено!';
progressBar.className = 'bg-green-600 h-4 rounded-full'; progressBar.className = 'bg-green-600 h-4 rounded-full';
} else { } else {
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...'; progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
@@ -816,7 +826,7 @@ function recalculateAll() {
console.error('Fetch error:', e); console.error('Fetch error:', e);
alert('Ошибка соединения'); alert('Ошибка соединения');
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Пересчитать цены'; btn.textContent = 'Обновить цены';
progressContainer.style.display = 'none'; progressContainer.style.display = 'none';
}); });
} }
@@ -965,6 +975,10 @@ function renderPricelists(pricelists) {
const statusText = pl.is_active ? 'Активен' : 'Неактивен'; const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`; let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (pricelistsCanWrite) {
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
}
if (pricelistsCanWrite && pl.usage_count === 0) { if (pricelistsCanWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`; actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
} }
@@ -989,6 +1003,33 @@ function renderPricelists(pricelists) {
document.getElementById('pricelists-body').innerHTML = html; document.getElementById('pricelists-body').innerHTML = html;
} }
async function togglePricelistActive(id, isActive) {
// Check if online before toggling
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
return;
}
try {
const resp = await fetch(`/api/pricelists/${id}/active`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to update status');
}
showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
function renderPricelistsPagination(total, page, perPage) { function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage); const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) { if (totalPages <= 1) {
@@ -1024,6 +1065,7 @@ async function loadPricelistsDbUsername() {
function openPricelistsCreateModal() { function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden'); document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex'); document.getElementById('pricelists-create-modal').classList.add('flex');
resetPricelistCreateProgress();
loadPricelistsDbUsername(); loadPricelistsDbUsername();
} }
@@ -1049,20 +1091,88 @@ async function createPricelist() {
throw new Error('Создание прайслистов доступно только в онлайн режиме'); throw new Error('Создание прайслистов доступно только в онлайн режиме');
} }
const resp = await fetch('/api/pricelists', { const progressBox = document.getElementById('pricelist-create-progress');
method: 'POST', const progressBar = document.getElementById('pricelist-create-progress-bar');
headers: { const progressText = document.getElementById('pricelist-create-progress-text');
'Content-Type': 'application/json' const progressPercent = document.getElementById('pricelist-create-progress-percent');
}, const progressStats = document.getElementById('pricelist-create-progress-stats');
body: JSON.stringify({})
progressBox.classList.remove('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Запуск создания прайслиста...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST'
}); });
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json(); let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Failed to create pricelist'); throw new Error(data.error || 'Failed to create pricelist');
} }
return await resp.json(); const reader = resp.body.getReader();
const decoder = new TextDecoder();
let completedPricelist = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let data;
try {
data = JSON.parse(line.slice(5).trim());
} catch (_) {
continue;
}
const current = Number(data.current || 0);
const total = Number(data.total || 0);
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.lot_name) {
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
} else {
progressText.textContent = data.message || 'Обработка...';
}
if (data.updated !== undefined || data.errors !== undefined) {
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
}
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка создания прайслиста');
}
if (data.status === 'completed' && data.pricelist) {
completedPricelist = data.pricelist;
}
}
}
if (!completedPricelist) {
throw new Error('Создание прервано: не получен результат');
}
return completedPricelist;
}
function resetPricelistCreateProgress() {
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.add('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Подготовка...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
} }
async function deletePricelist(id) { async function deletePricelist(id) {

View File

@@ -16,7 +16,7 @@
</div> </div>
<div id="save-buttons" class="hidden flex items-center space-x-2"> <div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену Обновить цены
</button> </button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"> <button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить Сохранить
@@ -34,6 +34,14 @@
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()"> onchange="updateServerCount()">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
<select id="pricelist-select"
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updatePricelistSelection()">
<option value="">Загрузка...</option>
</select>
</div>
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span> <span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div> </div>
@@ -224,6 +232,7 @@ let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration let serverCount = 1; // Server count for the configuration
let selectedPricelistId = null; // Selected pricelist (server ID)
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
@@ -296,6 +305,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverCount = config.server_count || 1; serverCount = config.server_count || 1;
document.getElementById('server-count').value = serverCount; document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount; document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistId = config.pricelist_id || null;
if (config.items && config.items.length > 0) { if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({ cart = config.items.map(item => ({
@@ -322,6 +332,7 @@ document.addEventListener('DOMContentLoaded', async function() {
return; return;
} }
await loadActivePricelists();
await loadAllComponents(); await loadAllComponents();
renderTab(); renderTab();
updateCartUI(); updateCartUI();
@@ -361,6 +372,44 @@ function updateServerCount() {
triggerAutoSave(); triggerAutoSave();
} }
async function loadActivePricelists() {
const select = document.getElementById('pricelist-select');
if (!select) return;
try {
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
const data = await resp.json();
const pricelists = data.pricelists || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
selectedPricelistId = null;
return;
}
select.innerHTML = pricelists.map(pl => {
return `<option value="${pl.id}">${pl.version}</option>`;
}).join('');
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
if (existing) {
select.value = String(selectedPricelistId);
} else {
selectedPricelistId = Number(pricelists[0].id);
select.value = String(selectedPricelistId);
}
} catch (e) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function updatePricelistSelection() {
const select = document.getElementById('pricelist-select');
const next = parseInt(select.value);
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
triggerAutoSave();
}
function getCategoryFromLotName(lotName) { function getCategoryFromLotName(lotName) {
const parts = lotName.split('_'); const parts = lotName.split('_');
return parts[0] || ''; return parts[0] || '';
@@ -1133,7 +1182,8 @@ async function saveConfig(showNotification = true) {
items: cart, items: cart,
custom_price: customPrice, custom_price: customPrice,
notes: '', notes: '',
server_count: serverCountValue server_count: serverCountValue,
pricelist_id: selectedPricelistId
}) })
}); });
@@ -1327,6 +1377,17 @@ async function refreshPrices() {
if (config.price_updated_at) { if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at); updatePriceUpdateDate(config.price_updated_at);
} }
if (config.pricelist_id) {
selectedPricelistId = config.pricelist_id;
const select = document.getElementById('pricelist-select');
if (select) {
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
if (!hasOption) {
await loadActivePricelists();
}
select.value = String(selectedPricelistId);
}
}
// Re-render UI // Re-render UI
renderTab(); renderTab();

View File

@@ -147,12 +147,15 @@
let settings = []; let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0; const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== ''; const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator // Method indicator
if (hasManualPrice) { if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>'); settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (item.price_method === 'average') { } else if (method === 'average') {
settings.push('Сред'); settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else { } else {
settings.push('Мед'); settings.push('Мед');
} }