WIP: save current pricing and pricelist changes

This commit is contained in:
Mikhail Chusavitin
2026-02-06 19:07:22 +03:00
parent 29035ddc5a
commit b965c6bb95
18 changed files with 1263 additions and 182 deletions

View File

@@ -480,7 +480,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if mariaDB != nil { if mariaDB != nil {
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing) pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo) componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService) quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, 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, pricingService) pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
@@ -488,7 +488,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// 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)
componentService = services.NewComponentService(nil, nil, nil) componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, pricingService) quoteService = services.NewQuoteService(nil, nil, nil, local, 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, nil) pricelistService = pricelist.NewService(nil, nil, nil, nil)
@@ -691,6 +691,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
{ {
quote.POST("/validate", quoteHandler.Validate) quote.POST("/validate", quoteHandler.Validate)
quote.POST("/calculate", quoteHandler.Calculate) quote.POST("/calculate", quoteHandler.Calculate)
quote.POST("/price-levels", quoteHandler.PriceLevels)
} }
// Export (public) // Export (public)

View File

@@ -1,11 +1,14 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist" "git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -24,6 +27,7 @@ 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" activeOnly := c.DefaultQuery("active_only", "false") == "true"
source := c.Query("source")
var ( var (
pricelists any pricelists any
@@ -32,9 +36,9 @@ func (h *PricelistHandler) List(c *gin.Context) {
) )
if activeOnly { if activeOnly {
pricelists, total, err = h.service.ListActive(page, perPage) pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
} else { } else {
pricelists, total, err = h.service.List(page, perPage) pricelists, total, err = h.service.ListBySource(page, perPage, source)
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -45,11 +49,21 @@ func (h *PricelistHandler) List(c *gin.Context) {
if total == 0 && h.localDB != nil { if total == 0 && h.localDB != nil {
localPLs, err := h.localDB.GetLocalPricelists() localPLs, err := h.localDB.GetLocalPricelists()
if err == nil && len(localPLs) > 0 { if err == nil && len(localPLs) > 0 {
if source != "" {
filtered := localPLs[:0]
for _, lpl := range localPLs {
if lpl.Source == source {
filtered = append(filtered, lpl)
}
}
localPLs = filtered
}
// Convert to PricelistSummary format // Convert to PricelistSummary format
summaries := make([]map[string]interface{}, len(localPLs)) summaries := make([]map[string]interface{}, len(localPLs))
for i, lpl := range localPLs { for i, lpl := range localPLs {
summaries[i] = map[string]interface{}{ summaries[i] = map[string]interface{}{
"id": lpl.ServerID, "id": lpl.ServerID,
"source": lpl.Source,
"version": lpl.Version, "version": lpl.Version,
"created_by": "sync", "created_by": "sync",
"item_count": 0, // Not tracked "item_count": 0, // Not tracked
@@ -108,13 +122,33 @@ func (h *PricelistHandler) Create(c *gin.Context) {
return return
} }
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
// Get the database username as the creator // Get the database username as the creator
createdBy := h.localDB.GetDBUser() createdBy := h.localDB.GetDBUser()
if createdBy == "" { if createdBy == "" {
createdBy = "unknown" createdBy = "unknown"
} }
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
pl, err := h.service.CreateFromCurrentPrices(createdBy) pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
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
@@ -134,10 +168,30 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
return return
} }
var req struct {
Source string `json:"source"`
Items []struct {
LotName string `json:"lot_name"`
Price float64 `json:"price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
source := string(models.NormalizePricelistSource(req.Source))
createdBy := h.localDB.GetDBUser() createdBy := h.localDB.GetDBUser()
if createdBy == "" { if createdBy == "" {
createdBy = "unknown" createdBy = "unknown"
} }
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
for _, item := range req.Items {
sourceItems = append(sourceItems, pricelist.CreateItemInput{
LotName: item.LotName,
Price: item.Price,
})
}
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@@ -146,7 +200,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
flusher, ok := c.Writer.(http.Flusher) flusher, ok := c.Writer.(http.Flusher)
if !ok { if !ok {
pl, err := h.service.CreateFromCurrentPrices(createdBy) pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
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
@@ -161,7 +215,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
} }
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."}) sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) { pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
sendProgress(gin.H{ sendProgress(gin.H{
"current": p.Current, "current": p.Current,
"total": p.Total, "total": p.Total,
@@ -269,8 +323,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
pl, _ := h.service.GetByID(uint(id))
source := ""
if pl != nil {
source = pl.Source
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"source": source,
"items": items, "items": items,
"total": total, "total": total,
"page": page, "page": page,
@@ -286,15 +346,18 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
// GetLatest returns the most recent active pricelist // GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) { func (h *PricelistHandler) GetLatest(c *gin.Context) {
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
source = string(models.NormalizePricelistSource(source))
// Try to get from server first // Try to get from server first
pl, err := h.service.GetLatestActive() pl, err := h.service.GetLatestActiveBySource(source)
if err != nil { if err != nil {
// If offline or no server pricelists, try to get from local cache // If offline or no server pricelists, try to get from local cache
if h.localDB == nil { if h.localDB == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"}) c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
return return
} }
localPL, localErr := h.localDB.GetLatestLocalPricelist() localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
if localErr != nil { if localErr != nil {
// No local pricelists either // No local pricelists either
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -306,6 +369,7 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
// Return local pricelist // Return local pricelist
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"id": localPL.ServerID, "id": localPL.ServerID,
"source": localPL.Source,
"version": localPL.Version, "version": localPL.Version,
"created_by": "sync", "created_by": "sync",
"item_count": 0, // Not tracked in local pricelists "item_count": 0, // Not tracked in local pricelists

View File

@@ -3,8 +3,8 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type QuoteHandler struct { type QuoteHandler struct {
@@ -49,3 +49,19 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
"total": result.Total, "total": result.Total,
}) })
} }
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View File

@@ -385,10 +385,9 @@ func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
return nil return nil
} }
// If we have components but no prices, we should load prices from pricelists // If we have components but no prices, load from latest estimate pricelist.
// Find the latest pricelist
var latestPricelist LocalPricelist var latestPricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil { if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
slog.Warn("no pricelists found in local database") slog.Warn("no pricelists found in local database")
return nil return nil

View File

@@ -139,6 +139,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
return &LocalPricelist{ return &LocalPricelist{
ServerID: pl.ID, ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Name: name, Name: name,
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
@@ -151,6 +152,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
func LocalToPricelist(local *LocalPricelist) *models.Pricelist { func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
return &models.Pricelist{ return &models.Pricelist{
ID: local.ServerID, ID: local.ServerID,
Source: local.Source,
Version: local.Version, Version: local.Version,
Notification: local.Name, Notification: local.Name,
CreatedAt: local.CreatedAt, CreatedAt: local.CreatedAt,

View File

@@ -523,7 +523,16 @@ func (l *LocalDB) CountLocalPricelists() int64 {
// GetLatestLocalPricelist returns the most recently synced pricelist // GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil { if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err return nil, err
} }
return &pricelist, nil return &pricelist, nil
@@ -541,7 +550,16 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
// GetLocalPricelistByVersion returns a local pricelist by version string. // GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) { func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
var pricelist LocalPricelist var pricelist LocalPricelist
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil { if err := l.db.Where("version = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, err return nil, err
} }
return &pricelist, nil return &pricelist, nil
@@ -561,6 +579,7 @@ func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Clauses(clause.OnConflict{ return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "server_id"}}, Columns: []clause.Column{{Name: "server_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{ DoUpdates: clause.Assignments(map[string]interface{}{
"source": pricelist.Source,
"version": pricelist.Version, "version": pricelist.Version,
"name": pricelist.Name, "name": pricelist.Name,
"created_at": pricelist.CreatedAt, "created_at": pricelist.CreatedAt,

View File

@@ -53,6 +53,11 @@ var localMigrations = []localMigration{
name: "Use unique server_id for local pricelists and allow duplicate versions", name: "Use unique server_id for local pricelists and allow duplicate versions",
run: fixLocalPricelistIndexes, run: fixLocalPricelistIndexes,
}, },
{
id: "2026_02_06_pricelist_source",
name: "Backfill source for local pricelists and create source indexes",
run: backfillLocalPricelistSource,
},
} }
func runLocalMigrations(db *gorm.DB) error { func runLocalMigrations(db *gorm.DB) error {
@@ -205,7 +210,7 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
func backfillConfigurationPricelists(tx *gorm.DB) error { func backfillConfigurationPricelists(tx *gorm.DB) error {
var latest LocalPricelist var latest LocalPricelist
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil { if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil return nil
} }
@@ -292,3 +297,22 @@ func fixLocalPricelistIndexes(tx *gorm.DB) error {
return nil return nil
} }
func backfillLocalPricelistSource(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE local_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = ''
`).Error; err != nil {
return fmt.Errorf("backfill local_pricelists.source: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
ON local_pricelists(source, created_at DESC)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
}
return nil
}

View File

@@ -128,9 +128,10 @@ func (LocalConfigurationVersion) TableName() string {
type LocalPricelist struct { type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
Version string `gorm:"not null;index" json:"version"` Version string `gorm:"not null;index" json:"version"`
Name string `json:"name"` Name string `json:"name"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
SyncedAt time.Time `json:"synced_at"` SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
} }

View File

@@ -54,6 +54,9 @@ type Configuration struct {
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"` PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
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

@@ -4,12 +4,41 @@ import (
"time" "time"
) )
type PricelistSource string
const (
PricelistSourceEstimate PricelistSource = "estimate"
PricelistSourceWarehouse PricelistSource = "warehouse"
PricelistSourceCompetitor PricelistSource = "competitor"
)
func (s PricelistSource) IsValid() bool {
switch s {
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor:
return true
default:
return false
}
}
func NormalizePricelistSource(source string) PricelistSource {
switch PricelistSource(source) {
case PricelistSourceWarehouse:
return PricelistSourceWarehouse
case PricelistSourceCompetitor:
return PricelistSourceCompetitor
default:
return PricelistSourceEstimate
}
}
// Pricelist represents a versioned snapshot of prices // Pricelist represents a versioned snapshot of prices
type Pricelist struct { type Pricelist struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
CreatedBy string `gorm:"size:100" json:"created_by"` CreatedBy string `gorm:"size:100" json:"created_by"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
UsageCount int `gorm:"default:0" json:"usage_count"` UsageCount int `gorm:"default:0" json:"usage_count"`
@@ -47,6 +76,7 @@ func (PricelistItem) TableName() string {
// PricelistSummary is used for list views // PricelistSummary is used for list views
type PricelistSummary struct { type PricelistSummary struct {
ID uint `json:"id"` ID uint `json:"id"`
Source string `json:"source"`
Version string `json:"version"` Version string `json:"version"`
Notification string `json:"notification"` Notification string `json:"notification"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`

View File

@@ -21,13 +21,23 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
// List returns pricelists with pagination // List returns pricelists with pagination
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) { func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListBySource("", offset, limit)
}
// ListBySource returns pricelists filtered by source when provided.
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{})
if source != "" {
query = query.Where("source = ?", source)
}
var total int64 var total int64
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting pricelists: %w", err) return nil, 0, fmt.Errorf("counting pricelists: %w", err)
} }
var pricelists []models.Pricelist var pricelists []models.Pricelist
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelists: %w", err) return nil, 0, fmt.Errorf("listing pricelists: %w", err)
} }
@@ -36,13 +46,23 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
// ListActive returns active pricelists with pagination. // ListActive returns active pricelists with pagination.
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) { func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
return r.ListActiveBySource("", offset, limit)
}
// ListActiveBySource returns active pricelists filtered by source when provided.
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
if source != "" {
query = query.Where("source = ?", source)
}
var total int64 var total int64
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting active pricelists: %w", err) return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
} }
var pricelists []models.Pricelist var pricelists []models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil { if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing active pricelists: %w", err) return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
} }
@@ -68,6 +88,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
summaries[i] = models.PricelistSummary{ summaries[i] = models.PricelistSummary{
ID: pl.ID, ID: pl.ID,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Notification: pl.Notification, Notification: pl.Notification,
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
@@ -102,8 +123,13 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
// GetByVersion returns a pricelist by version string // GetByVersion returns a pricelist by version string
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) { func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
}
// GetBySourceAndVersion returns a pricelist by source/version.
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
var pricelist models.Pricelist var pricelist models.Pricelist
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil { if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting pricelist by version: %w", err) return nil, fmt.Errorf("getting pricelist by version: %w", err)
} }
return &pricelist, nil return &pricelist, nil
@@ -111,8 +137,13 @@ func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, e
// GetLatestActive returns the most recent active pricelist // GetLatestActive returns the most recent active pricelist
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) { func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
}
// GetLatestActiveBySource returns the most recent active pricelist by source.
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
var pricelist models.Pricelist var pricelist models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil { if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting latest pricelist: %w", err) return nil, fmt.Errorf("getting latest pricelist: %w", err)
} }
return &pricelist, nil return &pricelist, nil
@@ -228,12 +259,17 @@ func (r *PricelistRepository) SetActive(id uint, isActive bool) 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) {
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
}
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
var last models.Pricelist var last models.Pricelist
err := r.db.Model(&models.Pricelist{}). err := r.db.Model(&models.Pricelist{}).
Select("version"). Select("version").
Where("version LIKE ?", today+"-%"). Where("source = ? AND version LIKE ?", source, today+"-%").
Order("version DESC"). Order("version DESC").
Limit(1). Limit(1).
Take(&last).Error Take(&last).Error
@@ -257,6 +293,19 @@ func (r *PricelistRepository) GenerateVersion() (string, error) {
return fmt.Sprintf("%s-%03d", today, n+1), nil return fmt.Sprintf("%s-%03d", today, n+1), nil
} }
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
latest, err := r.GetLatestActiveBySource(source)
if err != nil {
return 0, 0, err
}
price, err := r.GetPriceForLot(latest.ID, lotName)
if err != nil {
return 0, 0, err
}
return price, latest.ID, nil
}
// CanWrite checks if the current database user has INSERT permission on qt_pricelists // CanWrite checks if the current database user has INSERT permission on qt_pricelists
func (r *PricelistRepository) CanWrite() bool { func (r *PricelistRepository) CanWrite() bool {
canWrite, _ := r.CanWriteDebug() canWrite, _ := r.CanWriteDebug()

View File

@@ -13,9 +13,9 @@ import (
func TestGenerateVersion_FirstOfDay(t *testing.T) { func TestGenerateVersion_FirstOfDay(t *testing.T) {
repo := newTestPricelistRepository(t) repo := newTestPricelistRepository(t)
version, err := repo.GenerateVersion() version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil { if err != nil {
t.Fatalf("GenerateVersion returned error: %v", err) t.Fatalf("GenerateVersionBySource returned error: %v", err)
} }
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{ seed := []models.Pricelist{
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true}, {Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
} }
for _, pl := range seed { for _, pl := range seed {
if err := repo.Create(&pl); err != nil { if err := repo.Create(&pl); err != nil {
@@ -39,9 +39,9 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
} }
} }
version, err := repo.GenerateVersion() version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
if err != nil { if err != nil {
t.Fatalf("GenerateVersion returned error: %v", err) t.Fatalf("GenerateVersionBySource returned error: %v", err)
} }
want := fmt.Sprintf("%s-004", today) want := fmt.Sprintf("%s-004", today)
@@ -50,6 +50,31 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
} }
} }
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
repo := newTestPricelistRepository(t)
today := time.Now().Format("2006-01-02")
seed := []models.Pricelist{
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-009", today), CreatedBy: "test", IsActive: true},
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("%s-002", 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.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
if err != nil {
t.Fatalf("GenerateVersionBySource returned error: %v", err)
}
want := fmt.Sprintf("%s-003", today)
if version != want {
t.Fatalf("expected %s, got %s", want, version)
}
}
func newTestPricelistRepository(t *testing.T) *PricelistRepository { func newTestPricelistRepository(t *testing.T) *PricelistRepository {
t.Helper() t.Helper()

View File

@@ -30,6 +30,11 @@ type CreateProgress struct {
LotName string LotName string
} }
type CreateItemInput struct {
LotName string
Price float64
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service { func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
return &Service{ return &Service{
repo: repo, repo: repo,
@@ -41,14 +46,25 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
// 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) 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. // CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) { 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 { 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")
} }
source = string(models.NormalizePricelistSource(source))
report := func(p CreateProgress) { report := func(p CreateProgress) {
if onProgress != nil { if onProgress != nil {
@@ -58,7 +74,7 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"}) report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
updated, errs := 0, 0 updated, errs := 0, 0
if s.pricingSvc != nil { if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"}) report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) { updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
if p.Total <= 0 { if p.Total <= 0 {
@@ -86,12 +102,13 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
const maxCreateAttempts = 5 const maxCreateAttempts = 5
var pricelist *models.Pricelist var pricelist *models.Pricelist
for attempt := 1; attempt <= maxCreateAttempts; attempt++ { for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
version, err := s.repo.GenerateVersion() version, err := s.repo.GenerateVersionBySource(source)
if err != nil { if err != nil {
return nil, fmt.Errorf("generating version: %w", err) return nil, fmt.Errorf("generating version: %w", err)
} }
pricelist = &models.Pricelist{ pricelist = &models.Pricelist{
Source: source,
Version: version, Version: version,
CreatedBy: createdBy, CreatedBy: createdBy,
IsActive: true, IsActive: true,
@@ -113,14 +130,28 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
break break
} }
// Get all components with prices from qt_lot_metadata items := make([]models.PricelistItem, 0)
if len(sourceItems) > 0 {
items = make([]models.PricelistItem, 0, len(sourceItems))
for _, srcItem := range sourceItems {
if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: strings.TrimSpace(srcItem.LotName),
Price: srcItem.Price,
})
}
} else {
// Default snapshot source for estimate and backward compatibility.
var metadata []models.LotMetadata var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil { 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) return nil, fmt.Errorf("getting lot metadata: %w", err)
} }
// Create pricelist items with all price settings // Create pricelist items with all price settings
items := make([]models.PricelistItem, 0, len(metadata)) items = make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata { for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 { if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue continue
@@ -136,6 +167,7 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
MetaPrices: m.MetaPrices, MetaPrices: m.MetaPrices,
}) })
} }
}
if err := s.repo.CreateItems(items); err != nil { if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails // Clean up the pricelist if items creation fails
@@ -161,11 +193,17 @@ func isVersionConflictError(err error) bool {
return true return true
} }
msg := strings.ToLower(err.Error()) msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version") 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 // List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { 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 no database connection (offline mode), return empty list
if s.repo == nil { if s.repo == nil {
return []models.PricelistSummary{}, 0, nil return []models.PricelistSummary{}, 0, nil
@@ -178,11 +216,16 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
perPage = 20 perPage = 20
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
return s.repo.List(offset, perPage) return s.repo.ListBySource(source, offset, perPage)
} }
// ListActive returns active pricelists with pagination. // ListActive returns active pricelists with pagination.
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) { 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 { if s.repo == nil {
return []models.PricelistSummary{}, 0, nil return []models.PricelistSummary{}, 0, nil
} }
@@ -193,7 +236,7 @@ func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int6
perPage = 20 perPage = 20
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
return s.repo.ListActive(offset, perPage) return s.repo.ListActiveBySource(source, offset, perPage)
} }
// GetByID returns a pricelist by ID // GetByID returns a pricelist by ID
@@ -261,10 +304,15 @@ func (s *Service) CanWriteDebug() (bool, string) {
// GetLatestActive returns the most recent active pricelist // GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) { 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 { if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available") return nil, fmt.Errorf("offline mode: pricelist service not available")
} }
return s.repo.GetLatestActive() return s.repo.GetLatestActiveBySource(source)
} }
// CleanupExpired deletes expired and unused pricelists // CleanupExpired deletes expired and unused pricelists

View File

@@ -3,6 +3,7 @@ package services
import ( import (
"errors" "errors"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"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" "git.mchus.pro/mchus/quoteforge/internal/services/pricing"
@@ -17,17 +18,23 @@ var (
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService *pricing.Service pricingService *pricing.Service
} }
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository, componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository, statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB,
pricingService *pricing.Service, pricingService *pricing.Service,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo, componentRepo: componentRepo,
statsRepo: statsRepo, statsRepo: statsRepo,
pricelistRepo: pricelistRepo,
localDB: localDB,
pricingService: pricingService, pricingService: pricingService,
} }
} }
@@ -57,6 +64,34 @@ type QuoteRequest struct {
} `json:"items"` } `json:"items"`
} }
type PriceLevelsRequest struct {
Items []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
} `json:"items"`
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
}
type PriceLevelsItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
EstimatePrice *float64 `json:"estimate_price"`
WarehousePrice *float64 `json:"warehouse_price"`
CompetitorPrice *float64 `json:"competitor_price"`
DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"`
DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"`
DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"`
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"`
PriceMissing []string `json:"price_missing"`
}
type PriceLevelsResult struct {
Items []PriceLevelsItem `json:"items"`
ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"`
}
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) { func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
@@ -130,6 +165,132 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
return result, nil return result, nil
} }
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
result := &PriceLevelsResult{
Items: make([]PriceLevelsItem, 0, len(req.Items)),
ResolvedPricelistIDs: map[string]uint{},
}
for _, reqItem := range req.Items {
item := PriceLevelsItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
PriceMissing: make([]string, 0, 3),
}
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
item.EstimatePrice = estimatePrice
item.WarehousePrice = warehousePrice
item.CompetitorPrice = competitorPrice
if estimateID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
}
if warehouseID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
}
if competitorID != 0 {
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
}
if item.EstimatePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate))
}
if item.WarehousePrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse))
}
if item.CompetitorPrice == nil {
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor))
}
item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice)
item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice)
item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice)
result.Items = append(result.Items, item)
}
return result, nil
}
func calculateDelta(target, base *float64) (*float64, *float64) {
if target == nil || base == nil {
return nil, nil
}
abs := *target - *base
if *base == 0 {
return &abs, nil
}
pct := (abs / *base) * 100
return &abs, &pct
}
func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) {
sourceKey := string(source)
if id, ok := pricelistIDs[sourceKey]; ok && id > 0 {
price, found := s.lookupPriceByPricelistID(id, lotName)
if found {
return &price, id
}
return nil, id
}
if s.pricelistRepo != nil {
price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName)
if err == nil && price > 0 {
return &price, id
}
latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
if latestErr == nil {
return nil, latest.ID
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
if err != nil {
return nil, 0
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil, localPL.ServerID
}
return &price, localPL.ServerID
}
return nil, 0
}
func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) {
if s.pricelistRepo != nil {
price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName)
if err == nil && price > 0 {
return price, true
}
}
if s.localDB != nil {
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
if err != nil {
return 0, false
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err == nil && price > 0 {
return price, true
}
}
return 0, false
}
// RecordUsage records that components were used in a quote // RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error { func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
if s.statsRepo == nil { if s.statsRepo == nil {

View File

@@ -0,0 +1,124 @@
package services
import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate
seedPricelistWithItem(t, repo, "warehouse", "CPU_X", 120)
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
Items: []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}{
{LotName: "CPU_X", Quantity: 2},
},
})
if err != nil {
t.Fatalf("CalculatePriceLevels returned error: %v", err)
}
if len(result.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(result.Items))
}
item := result.Items[0]
if item.EstimatePrice == nil || *item.EstimatePrice != 100 {
t.Fatalf("expected estimate 100, got %#v", item.EstimatePrice)
}
if item.WarehousePrice == nil || *item.WarehousePrice != 120 {
t.Fatalf("expected warehouse 120, got %#v", item.WarehousePrice)
}
if item.CompetitorPrice != nil {
t.Fatalf("expected competitor nil, got %#v", item.CompetitorPrice)
}
if len(item.PriceMissing) != 1 || item.PriceMissing[0] != "competitor" {
t.Fatalf("expected price_missing [competitor], got %#v", item.PriceMissing)
}
if item.DeltaWhEstimateAbs == nil || *item.DeltaWhEstimateAbs != 20 {
t.Fatalf("expected delta abs 20, got %#v", item.DeltaWhEstimateAbs)
}
if item.DeltaWhEstimatePct == nil || *item.DeltaWhEstimatePct != 20 {
t.Fatalf("expected delta pct 20, got %#v", item.DeltaWhEstimatePct)
}
}
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, nil, repo, nil, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
Items: []struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}{
{LotName: "CPU_Y", Quantity: 1},
},
PricelistIDs: map[string]uint{
"estimate": olderEstimate.ID,
},
})
if err != nil {
t.Fatalf("CalculatePriceLevels returned error: %v", err)
}
item := result.Items[0]
if item.EstimatePrice == nil || *item.EstimatePrice != 80 {
t.Fatalf("expected explicit estimate 80, got %#v", item.EstimatePrice)
}
}
func newPriceLevelsTestDB(t *testing.T) *gorm.DB {
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{}, &models.PricelistItem{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func seedPricelistWithItem(t *testing.T, repo *repository.PricelistRepository, source, lot string, price float64) *models.Pricelist {
t.Helper()
version, err := repo.GenerateVersionBySource(source)
if err != nil {
t.Fatalf("GenerateVersionBySource: %v", err)
}
expiresAt := time.Now().Add(24 * time.Hour)
pl := &models.Pricelist{
Source: source,
Version: version,
CreatedBy: "test",
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := repo.Create(pl); err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := repo.CreateItems([]models.PricelistItem{
{
PricelistID: pl.ID,
LotName: lot,
Price: price,
},
}); err != nil {
t.Fatalf("create items: %v", err)
}
return pl
}

View File

@@ -292,22 +292,29 @@ func (s *Service) NeedSync() (bool, error) {
} }
pricelistRepo := repository.NewPricelistRepository(mariaDB) pricelistRepo := repository.NewPricelistRepository(mariaDB)
latestServer, err := pricelistRepo.GetLatestActive() sources := []models.PricelistSource{
models.PricelistSourceEstimate,
models.PricelistSourceWarehouse,
models.PricelistSourceCompetitor,
}
for _, source := range sources {
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
if err != nil { if err != nil {
// If no pricelists on server, no need to sync // No active pricelist for this source yet.
return false, nil continue
} }
latestLocal, err := s.localDB.GetLatestLocalPricelist() latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
if err != nil { if err != nil {
// No local pricelists, need to sync // No local pricelist for an existing source on server.
return true, nil return true, nil
} }
// If server has newer pricelist, need sync // If server has newer pricelist for this source, need sync.
if latestServer.ID != latestLocal.ServerID { if latestServer.ID != latestLocal.ServerID {
return true, nil return true, nil
} }
}
return false, nil return false, nil
} }
@@ -332,16 +339,16 @@ func (s *Service) SyncPricelists() (int, error) {
} }
synced := 0 synced := 0
var latestLocalID uint var latestEstimateLocalID uint
var latestServerID uint var latestEstimateCreatedAt time.Time
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
// Already synced, track latest by server ID // Track latest estimate pricelist by created_at for component refresh.
if pl.ID > latestServerID { if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestServerID = pl.ID latestEstimateCreatedAt = pl.CreatedAt
latestLocalID = existing.ID latestEstimateLocalID = existing.ID
} }
continue continue
} }
@@ -349,6 +356,7 @@ func (s *Service) SyncPricelists() (int, error) {
// Create local pricelist // Create local pricelist
localPL := &localdb.LocalPricelist{ localPL := &localdb.LocalPricelist{
ServerID: pl.ID, ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version, Version: pl.Version,
Name: pl.Notification, // Using notification as name Name: pl.Notification, // Using notification as name
CreatedAt: pl.CreatedAt, CreatedAt: pl.CreatedAt,
@@ -370,16 +378,16 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
} }
if pl.ID > latestServerID { if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
latestServerID = pl.ID latestEstimateCreatedAt = pl.CreatedAt
latestLocalID = localPL.ID latestEstimateLocalID = localPL.ID
} }
synced++ synced++
} }
// Update component prices from latest pricelist // Update component prices from latest estimate pricelist only.
if latestLocalID > 0 { if latestEstimateLocalID > 0 {
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID) updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
if err != nil { if err != nil {
slog.Warn("failed to update component prices from pricelist", "error", err) slog.Warn("failed to update component prices from pricelist", "error", err)
} else { } else {

View File

@@ -0,0 +1,15 @@
ALTER TABLE qt_pricelists
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
UPDATE qt_pricelists
SET source = 'estimate'
WHERE source IS NULL OR source = '';
ALTER TABLE qt_pricelists
DROP INDEX IF EXISTS idx_qt_pricelists_version;
CREATE UNIQUE INDEX idx_qt_pricelists_source_version
ON qt_pricelists(source, version);
CREATE INDEX idx_qt_pricelists_source_created_at
ON qt_pricelists(source, created_at);

View File

@@ -15,9 +15,15 @@
</h1> </h1>
</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 id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Обновить цены Обновить цены
</button> </button>
<button type="button"
onclick="openPriceSettingsModal()"
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
title="Настройки цен">
Цены
</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">
Сохранить Сохранить
</button> </button>
@@ -34,13 +40,9 @@
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> <div class="text-sm text-gray-600">
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label> <div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
<select id="pricelist-select" <div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updatePricelistSelection()">
<option value="">Загрузка...</option>
</select>
</div> </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>
@@ -86,8 +88,16 @@
</div> </div>
<!-- Cart summary --> <!-- Cart summary -->
<div id="cart-summary" class="bg-white rounded-lg shadow p-4"> <div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
<h3 class="font-semibold mb-3">Итого конфигурация</h3> <button type="button"
onclick="toggleCartSummarySection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Итого конфигурация</span>
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="cart-summary-content" class="p-4">
<div id="cart-items" class="space-y-2 mb-4"></div> <div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center"> <div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold"> <div class="text-lg font-bold">
@@ -96,10 +106,19 @@
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button> <button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div> </div>
</div> </div>
</div>
<!-- Custom price section --> <!-- Custom price section -->
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4"> <div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<h3 class="font-semibold mb-3">Своя цена</h3> <button type="button"
onclick="toggleCustomPriceSection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Своя цена</span>
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="custom-price-content" class="p-4">
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<div class="flex-1"> <div class="flex-1">
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label> <label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
@@ -156,6 +175,83 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Sale price section -->
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleSalePriceSection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Цена продажи</span>
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="sale-price-content" class="p-4">
<div id="sale-prices" class="border-t pt-3">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
</tr>
</thead>
<tbody id="sale-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2">Итого</td>
<td class="px-3 py-2 text-right"></td>
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Price settings modal -->
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
<div class="px-5 py-4 border-b flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-5 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Не обновлять цены</span>
</label>
</div>
<div class="px-5 py-4 border-t flex justify-end gap-2">
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
</div>
</div>
</div> </div>
<!-- Autocomplete dropdown (shared) --> <!-- Autocomplete dropdown (shared) -->
@@ -232,7 +328,18 @@ 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) let selectedPricelistIds = {
estimate: null,
warehouse: null,
competitor: null
};
let disablePriceRefresh = false;
let activePricelistsBySource = {
estimate: [],
warehouse: [],
competitor: []
};
let priceLevelsRequestSeq = 0;
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
@@ -241,6 +348,101 @@ let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1; let autocompleteIndex = -1;
let autocompleteFiltered = []; let autocompleteFiltered = [];
function getDisplayPrice(item) {
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
return item.unit_price;
}
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
return item.estimate_price;
}
return 0;
}
function formatNumberRu(value) {
const rounded = Math.round(value);
return rounded
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
}
function formatMoney(value) {
return '$\u00A0' + formatNumberRu(value);
}
function formatPriceOrNA(value) {
if (typeof value !== 'number' || value <= 0) {
return 'N/A';
}
return formatMoney(value);
}
function formatDelta(abs, pct) {
if (typeof abs !== 'number') {
return 'N/A';
}
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
const absValue = Math.abs(abs);
if (typeof pct !== 'number') {
return sign + formatMoney(absValue);
}
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
}
async function refreshPriceLevels() {
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
return;
}
const seq = ++priceLevelsRequestSeq;
try {
const payload = {
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity
})),
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
};
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
return;
}
const data = await resp.json();
if (seq !== priceLevelsRequestSeq) {
return;
}
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
cart = cart.map(item => {
const levels = byLot.get(item.lot_name);
if (!levels) return item;
const next = { ...item, ...levels };
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
next.unit_price = levels.estimate_price;
}
return next;
});
if (data.resolved_pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
}
});
syncPriceSettingsControls();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
}
} catch(e) {
console.error('Failed to refresh price levels', e);
}
}
// Load categories from API and update tab configuration // Load categories from API and update tab configuration
async function loadCategoriesFromAPI() { async function loadCategoriesFromAPI() {
try { try {
@@ -305,13 +507,16 @@ 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; selectedPricelistIds.estimate = 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 => ({
lot_name: item.lot_name, lot_name: item.lot_name,
quantity: item.quantity, quantity: item.quantity,
unit_price: item.unit_price, unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '', description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category || getCategoryFromLotName(item.lot_name)
})); }));
@@ -332,8 +537,13 @@ document.addEventListener('DOMContentLoaded', async function() {
return; return;
} }
restoreLocalPriceSettings();
await loadActivePricelists(); await loadActivePricelists();
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
await loadAllComponents(); await loadAllComponents();
await refreshPriceLevels();
renderTab(); renderTab();
updateCartUI(); updateCartUI();
@@ -373,41 +583,148 @@ function updateServerCount() {
} }
async function loadActivePricelists() { async function loadActivePricelists() {
const select = document.getElementById('pricelist-select'); const sources = ['estimate', 'warehouse', 'competitor'];
if (!select) return; await Promise.all(sources.map(async source => {
try { try {
const resp = await fetch('/api/pricelists?active_only=true&per_page=200'); const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json(); const data = await resp.json();
const pricelists = data.pricelists || []; activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (pricelists.length === 0) { if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
selectedPricelistId = null;
return; return;
} }
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
select.innerHTML = pricelists.map(pl => { ? Number(activePricelistsBySource[source][0].id)
return `<option value="${pl.id}">${pl.version}</option>`; : null;
}).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) { } catch (e) {
select.innerHTML = '<option value="">Ошибка загрузки</option>'; activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
}));
}
function renderPricelistSelectOptions(selectId, source) {
const select = document.getElementById(selectId);
if (!select) return;
const pricelists = activePricelistsBySource[source] || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
select.value = '';
return;
}
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
}).join('');
const current = selectedPricelistIds[source];
select.value = current ? String(current) : '';
}
function syncPriceSettingsControls() {
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
if (disableCheckbox) {
disableCheckbox.checked = disablePriceRefresh;
} }
} }
function updatePricelistSelection() { function getPricelistVersionById(source, id) {
const select = document.getElementById('pricelist-select'); const pricelists = activePricelistsBySource[source] || [];
const next = parseInt(select.value); const found = pricelists.find(pl => Number(pl.id) === Number(id));
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null; return found ? found.version : null;
}
function renderPricelistSettingsSummary() {
const summary = document.getElementById('pricelist-settings-summary');
if (!summary) return;
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
}
function updateRefreshPricesButtonState() {
const refreshBtn = document.getElementById('refresh-prices-btn');
if (!refreshBtn) return;
if (disablePriceRefresh) {
refreshBtn.disabled = true;
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
refreshBtn.title = 'Обновление цен отключено в настройках';
} else {
refreshBtn.disabled = false;
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
refreshBtn.title = '';
}
}
function getPriceSettingsStorageKey() {
return `qf_price_settings_${configUUID || 'default'}`;
}
function persistLocalPriceSettings() {
try {
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
pricelist_ids: selectedPricelistIds,
disable_price_refresh: disablePriceRefresh
}));
} catch (e) {
// ignore localStorage failures
}
}
function restoreLocalPriceSettings() {
try {
const raw = localStorage.getItem(getPriceSettingsStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && parsed.pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
const next = parseInt(parsed.pricelist_ids[source]);
if (Number.isFinite(next) && next > 0) {
selectedPricelistIds[source] = next;
}
});
}
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
} catch (e) {
// ignore invalid localStorage payload
}
}
async function openPriceSettingsModal() {
await loadActivePricelists();
syncPriceSettingsControls();
renderPricelistSettingsSummary();
document.getElementById('price-settings-modal')?.classList.remove('hidden');
}
function closePriceSettingsModal() {
document.getElementById('price-settings-modal')?.classList.add('hidden');
}
function applyPriceSettings() {
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
disablePriceRefresh = disableVal;
updateRefreshPricesButtonState();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
closePriceSettingsModal();
refreshPriceLevels().then(() => {
renderTab();
updateCartUI();
triggerAutoSave(); triggerAutoSave();
});
} }
function getCategoryFromLotName(lotName) { function getCategoryFromLotName(lotName) {
@@ -482,7 +799,7 @@ function renderSingleSelectTab(categories) {
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th> <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th> <th class="px-3 py-2 w-10"></th>
@@ -499,8 +816,9 @@ function renderSingleSelectTab(categories) {
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null; const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const price = comp?.current_price || 0; const price = comp?.current_price || 0;
const estimate = selectedItem?.estimate_price ?? price;
const qty = selectedItem?.quantity || 1; const qty = selectedItem?.quantity || 1;
const total = price * qty; const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
@@ -518,14 +836,14 @@ function renderSingleSelectTab(categories) {
</div> </div>
</td> </td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td> <td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input type="number" min="1" value="${qty}" <input type="number" min="1" value="${qty}"
id="qty-${cat}" id="qty-${cat}"
onchange="updateSingleQuantity('${cat}', this.value)" onchange="updateSingleQuantity('${cat}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm"> class="w-16 px-2 py-1 border rounded text-center text-sm">
</td> </td>
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td> <td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
${selectedItem ? ` ${selectedItem ? `
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700"> <button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
@@ -557,7 +875,7 @@ function renderMultiSelectTab(components) {
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th> <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th> <th class="px-3 py-2 w-10"></th>
@@ -569,19 +887,19 @@ function renderMultiSelectTab(components) {
// Render existing cart items for this tab // Render existing cart items for this tab
tabItems.forEach((item, idx) => { tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity; const total = getDisplayPrice(item) * item.quantity;
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}" <input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)" onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm"> class="w-16 px-2 py-1 border rounded text-center text-sm">
</td> </td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td> <td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700"> <button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -607,7 +925,7 @@ function renderMultiSelectTab(components) {
onkeydown="handleAutocompleteKeyMulti(event)"> onkeydown="handleAutocompleteKeyMulti(event)">
</div> </div>
</td> </td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price"></td> <td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty" <input type="number" min="1" value="1" id="new-qty"
class="w-16 px-2 py-1 border rounded text-center text-sm"> class="w-16 px-2 py-1 border rounded text-center text-sm">
@@ -658,7 +976,7 @@ function renderMultiSelectTabWithSections(sections) {
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th> <th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th> <th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th> <th class="px-3 py-2 w-10"></th>
@@ -670,19 +988,19 @@ function renderMultiSelectTabWithSections(sections) {
// Render existing cart items for this section // Render existing cart items for this section
sectionItems.forEach((item) => { sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name); const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity; const total = getDisplayPrice(item) * item.quantity;
html += ` html += `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td> <td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td> <td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}" <input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)" onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm"> class="w-16 px-2 py-1 border rounded text-center text-sm">
</td> </td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td> <td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700"> <button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -711,7 +1029,7 @@ function renderMultiSelectTabWithSections(sections) {
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')"> onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
</div> </div>
</td> </td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}"></td> <td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
<td class="px-3 py-2 text-center"> <td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty-${sectionId}" <input type="number" min="1" value="1" id="new-qty-${sectionId}"
class="w-16 px-2 py-1 border rounded text-center text-sm"> class="w-16 px-2 py-1 border rounded text-center text-sm">
@@ -833,14 +1151,26 @@ function selectAutocompleteItem(index) {
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '', description: comp.description || '',
category: getComponentCategory(comp) category: getComponentCategory(comp)
}); });
hideAutocomplete(); hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave(); triggerAutoSave();
});
} }
function hideAutocomplete() { function hideAutocomplete() {
@@ -913,14 +1243,26 @@ function selectAutocompleteItemMulti(index) {
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '', description: comp.description || '',
category: getComponentCategory(comp) category: getComponentCategory(comp)
}); });
hideAutocomplete(); hideAutocomplete();
refreshPriceLevels().then(() => {
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave(); triggerAutoSave();
});
} }
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections) // Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
@@ -1000,6 +1342,16 @@ function selectAutocompleteItemSection(index, sectionId) {
lot_name: comp.lot_name, lot_name: comp.lot_name,
quantity: qty, quantity: qty,
unit_price: comp.current_price, unit_price: comp.current_price,
estimate_price: comp.current_price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '', description: comp.description || '',
category: getComponentCategory(comp) category: getComponentCategory(comp)
}); });
@@ -1013,9 +1365,11 @@ function selectAutocompleteItemSection(index, sectionId) {
// Reset quantity to 1 // Reset quantity to 1
if (qtyInput) qtyInput.value = '1'; if (qtyInput) qtyInput.value = '1';
refreshPriceLevels().then(() => {
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave(); triggerAutoSave();
});
} }
function clearSingleSelect(category) { function clearSingleSelect(category) {
@@ -1054,7 +1408,7 @@ function updateMultiQuantity(lotName, value) {
if (row) { if (row) {
const totalCell = row.querySelector('td:nth-child(5)'); const totalCell = row.querySelector('td:nth-child(5)');
if (totalCell) { if (totalCell) {
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2); totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
} }
} }
} }
@@ -1068,11 +1422,12 @@ function removeFromCart(lotName) {
} }
function updateCartUI() { function updateCartUI() {
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0); const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}); document.getElementById('cart-total').textContent = formatMoney(total);
// Recalculate custom price section if active // Recalculate custom price section if active
calculateCustomPrice(); calculateCustomPrice();
renderSalePriceTable();
if (cart.length === 0) { if (cart.length === 0) {
document.getElementById('cart-items').innerHTML = document.getElementById('cart-items').innerHTML =
@@ -1116,7 +1471,7 @@ function updateCartUI() {
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`; html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
items.forEach(item => { items.forEach(item => {
const itemTotal = item.unit_price * item.quantity; const itemTotal = getDisplayPrice(item) * item.quantity;
html += ` html += `
<div class="flex justify-between items-center py-1 text-sm"> <div class="flex justify-between items-center py-1 text-sm">
<div class="flex-1"> <div class="flex-1">
@@ -1125,7 +1480,7 @@ function updateCartUI() {
<span>${item.quantity}</span> <span>${item.quantity}</span>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span> <span>${formatMoney(itemTotal)}</span>
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700"> <button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@@ -1183,7 +1538,7 @@ async function saveConfig(showNotification = true) {
custom_price: customPrice, custom_price: customPrice,
notes: '', notes: '',
server_count: serverCountValue, server_count: serverCountValue,
pricelist_id: selectedPricelistId pricelist_id: selectedPricelistIds.estimate
}) })
}); });
@@ -1226,12 +1581,143 @@ async function exportCSV() {
} }
} }
function formatLineTotalTooltip(qty, unitPrice) {
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
const lineTotal = qty * unitPrice;
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
}
function toggleSection(contentId, iconId) {
const content = document.getElementById(contentId);
const icon = document.getElementById(iconId);
if (!content || !icon) return;
const isHidden = content.classList.toggle('hidden');
if (isHidden) {
icon.classList.add('-rotate-90');
} else {
icon.classList.remove('-rotate-90');
}
}
function toggleCartSummarySection() {
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
}
function toggleCustomPriceSection() {
toggleSection('custom-price-content', 'custom-price-toggle-icon');
}
function toggleSalePriceSection() {
toggleSection('sale-price-content', 'sale-price-toggle-icon');
}
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
return `N/A от ${compareLabel}`;
}
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
}
function getTotalClass(current, references) {
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
return 'text-gray-900';
}
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
if (current < avg) return 'text-green-600';
if (current > avg) return 'text-red-600';
return 'text-gray-900';
}
function renderSalePriceTable() {
const body = document.getElementById('sale-prices-body');
const totalEstEl = document.getElementById('sale-total-est');
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
const totalCompetitorEl = document.getElementById('sale-total-competitor');
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
if (cart.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
totalEstEl.textContent = '$0.00';
totalWarehouseEl.textContent = '$0.00';
totalCompetitorEl.textContent = '$0.00';
totalEstEl.title = '';
totalWarehouseEl.title = '';
totalCompetitorEl.title = '';
totalEstEl.className = 'px-3 py-2 text-right';
totalWarehouseEl.className = 'px-3 py-2 text-right';
totalCompetitorEl.className = 'px-3 py-2 text-right';
return;
}
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = '';
let totalEstimate = 0;
let totalWarehouse = 0;
let totalCompetitor = 0;
sortedCart.forEach(item => {
const qty = item.quantity || 1;
const estPrice = item.estimate_price;
const warehousePrice = item.warehouse_price;
const competitorPrice = item.competitor_price;
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
const estTooltip = formatLineTotalTooltip(qty, estPrice);
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
html += `
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${qty}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
</tr>
`;
});
body.innerHTML = html;
totalEstEl.textContent = formatMoney(totalEstimate);
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
totalEstEl.title =
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
totalWarehouseEl.title =
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
totalCompetitorEl.title =
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
}
// Custom price functionality // Custom price functionality
function calculateCustomPrice() { function calculateCustomPrice() {
const customPriceInput = document.getElementById('custom-price-input'); const customPriceInput = document.getElementById('custom-price-input');
const customPrice = parseFloat(customPriceInput.value) || 0; const customPrice = parseFloat(customPriceInput.value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0); const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) { if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
document.getElementById('adjusted-prices').classList.add('hidden'); document.getElementById('adjusted-prices').classList.add('hidden');
@@ -1272,7 +1758,7 @@ function calculateCustomPrice() {
let totalNew = 0; let totalNew = 0;
sortedCart.forEach(item => { sortedCart.forEach(item => {
const originalPrice = item.unit_price; const originalPrice = getDisplayPrice(item);
const newPrice = originalPrice * coefficient; const newPrice = originalPrice * coefficient;
const itemOriginalTotal = originalPrice * item.quantity; const itemOriginalTotal = originalPrice * item.quantity;
const itemNewTotal = newPrice * item.quantity; const itemNewTotal = newPrice * item.quantity;
@@ -1284,17 +1770,17 @@ function calculateCustomPrice() {
<tr> <tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td> <td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${item.quantity}</td> <td class="px-3 py-2 text-right">${item.quantity}</td>
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td> <td class="px-3 py-2 text-right text-gray-500">${formatMoney(originalPrice)}</td>
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td> <td class="px-3 py-2 text-right text-green-600">${formatMoney(newPrice)}</td>
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td> <td class="px-3 py-2 text-right">${formatMoney(itemNewTotal)}</td>
</tr> </tr>
`; `;
}); });
document.getElementById('adjusted-prices-body').innerHTML = html; document.getElementById('adjusted-prices-body').innerHTML = html;
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2); document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal);
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2); document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew);
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2); document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew);
document.getElementById('adjusted-prices').classList.remove('hidden'); document.getElementById('adjusted-prices').classList.remove('hidden');
} }
@@ -1309,7 +1795,7 @@ async function exportCSVWithCustomPrice() {
if (cart.length === 0) return; if (cart.length === 0) return;
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0; const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0); const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || originalTotal <= 0) { if (customPrice <= 0 || originalTotal <= 0) {
showToast('Введите целевую цену', 'error'); showToast('Введите целевую цену', 'error');
@@ -1321,7 +1807,7 @@ async function exportCSVWithCustomPrice() {
// Create adjusted cart // Create adjusted cart
const adjustedCart = cart.map(item => ({ const adjustedCart = cart.map(item => ({
...item, ...item,
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2)) unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
})); }));
try { try {
@@ -1346,6 +1832,10 @@ async function exportCSVWithCustomPrice() {
async function refreshPrices() { async function refreshPrices() {
// RBAC disabled - no token check required // RBAC disabled - no token check required
if (!configUUID) return; if (!configUUID) return;
if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error');
return;
}
try { try {
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', { const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
@@ -1368,6 +1858,9 @@ async function refreshPrices() {
lot_name: item.lot_name, lot_name: item.lot_name,
quantity: item.quantity, quantity: item.quantity,
unit_price: item.unit_price, unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '', description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category || getCategoryFromLotName(item.lot_name)
})); }));
@@ -1378,18 +1871,17 @@ async function refreshPrices() {
updatePriceUpdateDate(config.price_updated_at); updatePriceUpdateDate(config.price_updated_at);
} }
if (config.pricelist_id) { if (config.pricelist_id) {
selectedPricelistId = config.pricelist_id; selectedPricelistIds.estimate = config.pricelist_id;
const select = document.getElementById('pricelist-select'); if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
if (select) {
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
if (!hasOption) {
await loadActivePricelists(); await loadActivePricelists();
} }
select.value = String(selectedPricelistId); syncPriceSettingsControls();
} renderPricelistSettingsSummary();
persistLocalPriceSettings();
} }
// Re-render UI // Re-render UI
await refreshPriceLevels();
renderTab(); renderTab();
updateCartUI(); updateCartUI();