WIP: save current pricing and pricelist changes
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,6 +27,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||
source := c.Query("source")
|
||||
|
||||
var (
|
||||
pricelists any
|
||||
@@ -32,9 +36,9 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
)
|
||||
|
||||
if activeOnly {
|
||||
pricelists, total, err = h.service.ListActive(page, perPage)
|
||||
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
|
||||
} else {
|
||||
pricelists, total, err = h.service.List(page, perPage)
|
||||
pricelists, total, err = h.service.ListBySource(page, perPage, source)
|
||||
}
|
||||
if err != nil {
|
||||
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 {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
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
|
||||
summaries := make([]map[string]interface{}, len(localPLs))
|
||||
for i, lpl := range localPLs {
|
||||
summaries[i] = map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"source": lpl.Source,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked
|
||||
@@ -108,13 +122,33 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
||||
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
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -134,10 +168,30 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
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()
|
||||
if createdBy == "" {
|
||||
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("Cache-Control", "no-cache")
|
||||
@@ -146,7 +200,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -161,7 +215,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
}
|
||||
|
||||
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{
|
||||
"current": p.Current,
|
||||
"total": p.Total,
|
||||
@@ -269,8 +323,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pl, _ := h.service.GetByID(uint(id))
|
||||
source := ""
|
||||
if pl != nil {
|
||||
source = pl.Source
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": source,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
@@ -286,15 +346,18 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
|
||||
// GetLatest returns the most recent active pricelist
|
||||
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
|
||||
pl, err := h.service.GetLatestActive()
|
||||
pl, err := h.service.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
// If offline or no server pricelists, try to get from local cache
|
||||
if h.localDB == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||
return
|
||||
}
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
|
||||
if localErr != nil {
|
||||
// No local pricelists either
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -306,6 +369,7 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
// Return local pricelist
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked in local pricelists
|
||||
|
||||
@@ -3,8 +3,8 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
@@ -49,3 +49,19 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -385,10 +385,9 @@ func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have components but no prices, we should load prices from pricelists
|
||||
// Find the latest pricelist
|
||||
// If we have components but no prices, load from latest estimate pricelist.
|
||||
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 {
|
||||
slog.Warn("no pricelists found in local database")
|
||||
return nil
|
||||
|
||||
@@ -139,6 +139,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
|
||||
return &LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: name,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -151,6 +152,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
return &models.Pricelist{
|
||||
ID: local.ServerID,
|
||||
Source: local.Source,
|
||||
Version: local.Version,
|
||||
Notification: local.Name,
|
||||
CreatedAt: local.CreatedAt,
|
||||
|
||||
@@ -523,7 +523,16 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
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 &pricelist, nil
|
||||
@@ -541,7 +550,16 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
||||
// GetLocalPricelistByVersion returns a local pricelist by version string.
|
||||
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||
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 &pricelist, nil
|
||||
@@ -561,6 +579,7 @@ func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "server_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"source": pricelist.Source,
|
||||
"version": pricelist.Version,
|
||||
"name": pricelist.Name,
|
||||
"created_at": pricelist.CreatedAt,
|
||||
|
||||
@@ -53,6 +53,11 @@ var localMigrations = []localMigration{
|
||||
name: "Use unique server_id for local pricelists and allow duplicate versions",
|
||||
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 {
|
||||
@@ -205,7 +210,7 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
@@ -292,3 +297,22 @@ func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -128,9 +128,10 @@ func (LocalConfigurationVersion) TableName() string {
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
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"`
|
||||
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"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ type Configuration struct {
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
|
||||
@@ -4,12 +4,41 @@ import (
|
||||
"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
|
||||
type Pricelist struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
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
|
||||
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"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
@@ -47,6 +76,7 @@ func (PricelistItem) TableName() string {
|
||||
// PricelistSummary is used for list views
|
||||
type PricelistSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version"`
|
||||
Notification string `json:"notification"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -21,13 +21,23 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||
|
||||
// List returns pricelists with pagination
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -36,13 +46,23 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -68,6 +88,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Notification: pl.Notification,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -102,8 +123,13 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
|
||||
// GetByVersion returns a pricelist by version string
|
||||
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
|
||||
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 &pricelist, nil
|
||||
@@ -111,8 +137,13 @@ func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, e
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
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
|
||||
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 &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
|
||||
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")
|
||||
|
||||
var last models.Pricelist
|
||||
err := r.db.Model(&models.Pricelist{}).
|
||||
Select("version").
|
||||
Where("version LIKE ?", today+"-%").
|
||||
Where("source = ? AND version LIKE ?", source, today+"-%").
|
||||
Order("version DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error
|
||||
@@ -257,6 +293,19 @@ func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
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
|
||||
func (r *PricelistRepository) CanWrite() bool {
|
||||
canWrite, _ := r.CanWriteDebug()
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
|
||||
version, err := repo.GenerateVersion()
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
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 {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ type CreateProgress struct {
|
||||
LotName string
|
||||
}
|
||||
|
||||
type CreateItemInput struct {
|
||||
LotName string
|
||||
Price float64
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||
return &Service{
|
||||
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
|
||||
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.
|
||||
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 {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
report := func(p CreateProgress) {
|
||||
if onProgress != nil {
|
||||
@@ -58,7 +74,7 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
||||
|
||||
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: "Обновление цен компонентов"})
|
||||
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
||||
if p.Total <= 0 {
|
||||
@@ -86,12 +102,13 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
const maxCreateAttempts = 5
|
||||
var pricelist *models.Pricelist
|
||||
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||
version, err := s.repo.GenerateVersion()
|
||||
version, err := s.repo.GenerateVersionBySource(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
pricelist = &models.Pricelist{
|
||||
Source: source,
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
@@ -113,28 +130,43 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
break
|
||||
}
|
||||
|
||||
// Get all components with prices from qt_lot_metadata
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items := make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
items := 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
|
||||
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)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items = make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
@@ -161,11 +193,17 @@ func isVersionConflictError(err error) bool {
|
||||
return true
|
||||
}
|
||||
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
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListBySource returns pricelists with optional source filter.
|
||||
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
@@ -178,11 +216,16 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.List(offset, perPage)
|
||||
return s.repo.ListBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListActiveBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListActiveBySource returns active pricelists with optional source filter.
|
||||
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
@@ -193,7 +236,7 @@ func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int6
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.ListActive(offset, perPage)
|
||||
return s.repo.ListActiveBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
@@ -261,10 +304,15 @@ func (s *Service) CanWriteDebug() (bool, string) {
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GetLatestActiveBySource returns the latest active pricelist for a source.
|
||||
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetLatestActive()
|
||||
return s.repo.GetLatestActiveBySource(source)
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
@@ -17,17 +18,23 @@ var (
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService *pricing.Service
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService *pricing.Service,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +64,34 @@ type QuoteRequest struct {
|
||||
} `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) {
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrEmptyQuote
|
||||
@@ -130,6 +165,132 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
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
|
||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||
if s.statsRepo == nil {
|
||||
|
||||
124
internal/services/quote_price_levels_test.go
Normal file
124
internal/services/quote_price_levels_test.go
Normal 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
|
||||
}
|
||||
@@ -292,21 +292,28 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
latestServer, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
sources := []models.PricelistSource{
|
||||
models.PricelistSourceEstimate,
|
||||
models.PricelistSourceWarehouse,
|
||||
models.PricelistSourceCompetitor,
|
||||
}
|
||||
for _, source := range sources {
|
||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
||||
if err != nil {
|
||||
// No active pricelist for this source yet.
|
||||
continue
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
// No local pricelists, need to sync
|
||||
return true, nil
|
||||
}
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
||||
if err != nil {
|
||||
// No local pricelist for an existing source on server.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist, need sync
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
// If server has newer pricelist for this source, need sync.
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
@@ -332,16 +339,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var latestLocalID uint
|
||||
var latestServerID uint
|
||||
var latestEstimateLocalID uint
|
||||
var latestEstimateCreatedAt time.Time
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, track latest by server ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = existing.ID
|
||||
// Track latest estimate pricelist by created_at for component refresh.
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = existing.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -349,6 +356,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Create local pricelist
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: pl.Notification, // Using notification as name
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -370,16 +378,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = localPL.ID
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
// Update component prices from latest pricelist
|
||||
if latestLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||
// Update component prices from latest estimate pricelist only.
|
||||
if latestEstimateLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user