Remove admin pricing stack and prepare v1.0.4 release
This commit is contained in:
@@ -1,199 +0,0 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
alertRepo *repository.AlertRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
priceRepo *repository.PriceRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
config config.AlertsConfig
|
||||
pricingConfig config.PricingConfig
|
||||
}
|
||||
|
||||
func NewService(
|
||||
alertRepo *repository.AlertRepository,
|
||||
componentRepo *repository.ComponentRepository,
|
||||
priceRepo *repository.PriceRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
alertCfg config.AlertsConfig,
|
||||
pricingCfg config.PricingConfig,
|
||||
) *Service {
|
||||
return &Service{
|
||||
alertRepo: alertRepo,
|
||||
componentRepo: componentRepo,
|
||||
priceRepo: priceRepo,
|
||||
statsRepo: statsRepo,
|
||||
config: alertCfg,
|
||||
pricingConfig: pricingCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.alertRepo.List(filter, offset, perPage)
|
||||
}
|
||||
|
||||
func (s *Service) Acknowledge(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
|
||||
}
|
||||
|
||||
func (s *Service) Resolve(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
|
||||
}
|
||||
|
||||
func (s *Service) Ignore(id uint) error {
|
||||
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
|
||||
}
|
||||
|
||||
func (s *Service) GetNewAlertsCount() (int64, error) {
|
||||
return s.alertRepo.CountByStatus(models.AlertStatusNew)
|
||||
}
|
||||
|
||||
// CheckAndGenerateAlerts scans components and creates alerts
|
||||
func (s *Service) CheckAndGenerateAlerts() error {
|
||||
if !s.config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get top components by usage
|
||||
topComponents, err := s.statsRepo.GetTopComponents(100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stats := range topComponents {
|
||||
component, err := s.componentRepo.GetByLotName(stats.LotName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check high demand + stale price
|
||||
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check trending without price
|
||||
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check no recent quotes
|
||||
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
|
||||
if stats.QuotesLast30d < s.config.HighDemandThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comp.PriceUpdatedAt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
|
||||
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if alert already exists
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertHighDemandStalePrice,
|
||||
Severity: models.SeverityCritical,
|
||||
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
|
||||
Details: models.AlertDetails{
|
||||
"quotes_30d": stats.QuotesLast30d,
|
||||
"days_since_update": daysSinceUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
|
||||
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// trending_no_price: trend > 50% AND no price
|
||||
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertTrendingNoPrice,
|
||||
Severity: models.SeverityHigh,
|
||||
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
|
||||
Details: models.AlertDetails{
|
||||
"trend_percent": stats.TrendPercent,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
|
||||
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
|
||||
// no_recent_quotes: popular component, no supplier quotes > 90 days
|
||||
if stats.QuotesLast30d < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if quoteCount > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
alert := &models.PricingAlert{
|
||||
LotName: comp.LotName,
|
||||
AlertType: models.AlertNoRecentQuotes,
|
||||
Severity: models.SeverityMedium,
|
||||
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
|
||||
Details: models.AlertDetails{
|
||||
"quotes_30d": stats.QuotesLast30d,
|
||||
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
|
||||
},
|
||||
}
|
||||
|
||||
return s.alertRepo.Create(alert)
|
||||
}
|
||||
@@ -600,26 +600,6 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
|
||||
|
||||
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
|
||||
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
configs := make([]models.Configuration, len(localConfigs))
|
||||
configs = configs[:0]
|
||||
for _, lc := range localConfigs {
|
||||
if !matchesConfigStatus(lc.IsActive, status) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
|
||||
total := int64(len(configs))
|
||||
|
||||
// Apply pagination
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -628,17 +608,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
start := offset
|
||||
if start > len(configs) {
|
||||
start = len(configs)
|
||||
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
end := start + perPage
|
||||
if end > len(configs) {
|
||||
end = len(configs)
|
||||
configs := make([]models.Configuration, 0, len(localConfigs))
|
||||
for _, lc := range localConfigs {
|
||||
configs = append(configs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
|
||||
return configs[start:end], total, nil
|
||||
return configs, total, nil
|
||||
}
|
||||
|
||||
// ListTemplates returns all template configurations
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
package pricelist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *repository.PricelistRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
pricingSvc *pricing.Service
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type CreateProgress struct {
|
||||
Current int
|
||||
Total int
|
||||
Status string
|
||||
Message string
|
||||
Updated int
|
||||
Errors int
|
||||
LotName string
|
||||
}
|
||||
|
||||
type CreateItemInput struct {
|
||||
LotName string
|
||||
Price float64
|
||||
PriceMethod string
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
componentRepo: componentRepo,
|
||||
pricingSvc: pricingSvc,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
|
||||
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
|
||||
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
|
||||
}
|
||||
|
||||
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
||||
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
|
||||
}
|
||||
|
||||
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
|
||||
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
if s.repo == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
report := func(p CreateProgress) {
|
||||
if onProgress != nil {
|
||||
onProgress(p)
|
||||
}
|
||||
}
|
||||
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
||||
|
||||
updated, errs := 0, 0
|
||||
if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
|
||||
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
|
||||
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
||||
if p.Total <= 0 {
|
||||
return
|
||||
}
|
||||
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
|
||||
if phaseCurrent > 91 {
|
||||
phaseCurrent = 91
|
||||
}
|
||||
report(CreateProgress{
|
||||
Current: phaseCurrent,
|
||||
Total: 100,
|
||||
Status: "recalculating",
|
||||
Message: "Обновление цен компонентов",
|
||||
Updated: p.Updated,
|
||||
Errors: p.Errors,
|
||||
LotName: p.LotName,
|
||||
})
|
||||
})
|
||||
}
|
||||
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
|
||||
|
||||
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
|
||||
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
||||
const maxCreateAttempts = 5
|
||||
var pricelist *models.Pricelist
|
||||
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||
version, err := s.repo.GenerateVersionBySource(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
pricelist = &models.Pricelist{
|
||||
Source: source,
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(pricelist); err != nil {
|
||||
if isVersionConflictError(err) && attempt < maxCreateAttempts {
|
||||
slog.Warn("pricelist version conflict, retrying",
|
||||
"attempt", attempt,
|
||||
"version", version,
|
||||
"error", err,
|
||||
)
|
||||
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("creating pricelist: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
items := make([]models.PricelistItem, 0)
|
||||
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
|
||||
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
|
||||
if err != nil {
|
||||
_ = s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
|
||||
}
|
||||
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
|
||||
for _, item := range warehouseItems {
|
||||
sourceItems = append(sourceItems, CreateItemInput{
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
PriceMethod: item.PriceMethod,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(sourceItems) > 0 {
|
||||
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,
|
||||
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Default snapshot source for estimate and backward compatibility.
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
_ = s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
// Clean up the pricelist if items creation fails
|
||||
s.repo.Delete(pricelist.ID)
|
||||
return nil, fmt.Errorf("creating pricelist items: %w", err)
|
||||
}
|
||||
|
||||
pricelist.ItemCount = len(items)
|
||||
|
||||
slog.Info("pricelist created",
|
||||
"id", pricelist.ID,
|
||||
"version", pricelist.Version,
|
||||
"items", len(items),
|
||||
"created_by", createdBy,
|
||||
)
|
||||
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
||||
|
||||
return pricelist, nil
|
||||
}
|
||||
|
||||
func isVersionConflictError(err error) bool {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "duplicate entry") &&
|
||||
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListBySource returns pricelists with optional source filter.
|
||||
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.ListBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListActiveBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListActiveBySource returns active pricelists with optional source filter.
|
||||
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.ListActiveBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistItem{}, 0, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
||||
}
|
||||
|
||||
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
|
||||
if s.repo == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return s.repo.GetLotNames(pricelistID)
|
||||
}
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot delete pricelists")
|
||||
}
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// SetActive toggles active state for a pricelist.
|
||||
func (s *Service) SetActive(id uint, isActive bool) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot update pricelists")
|
||||
}
|
||||
return s.repo.SetActive(id, isActive)
|
||||
}
|
||||
|
||||
// GetPriceForLot returns price by pricelist/lot.
|
||||
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
||||
if s.repo == nil {
|
||||
return 0, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetPriceForLot(pricelistID, lotName)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
if s.repo == nil {
|
||||
return false
|
||||
}
|
||||
return s.repo.CanWrite()
|
||||
}
|
||||
|
||||
// CanWriteDebug returns write permission status with debug info
|
||||
func (s *Service) CanWriteDebug() (bool, string) {
|
||||
if s.repo == nil {
|
||||
return false, "offline mode"
|
||||
}
|
||||
return s.repo.CanWriteDebug()
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GetLatestActiveBySource returns the latest active pricelist for a source.
|
||||
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetLatestActiveBySource(source)
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
func (s *Service) CleanupExpired() (int, error) {
|
||||
if s.repo == nil {
|
||||
return 0, fmt.Errorf("offline mode: cleanup not available")
|
||||
}
|
||||
|
||||
expired, err := s.repo.GetExpiredUnused()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, pl := range expired {
|
||||
if err := s.repo.Delete(pl.ID); err != nil {
|
||||
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
slog.Info("cleaned up expired pricelists", "deleted", deleted)
|
||||
return deleted, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package pricelist
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCreateWarehousePricelistFromStockLog(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.StockLog{},
|
||||
&models.Lot{},
|
||||
&models.LotPartnumber{},
|
||||
); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
|
||||
t.Fatalf("seed mapping: %v", err)
|
||||
}
|
||||
|
||||
qty1 := 2.0
|
||||
qty2 := 8.0
|
||||
now := time.Now()
|
||||
rows := []models.StockLog{
|
||||
{Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1},
|
||||
{Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed stock log: %v", err)
|
||||
}
|
||||
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
svc := NewService(db, repo, nil, nil)
|
||||
|
||||
pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create warehouse pricelist: %v", err)
|
||||
}
|
||||
if pl.Source != string(models.PricelistSourceWarehouse) {
|
||||
t.Fatalf("unexpected source: %s", pl.Source)
|
||||
}
|
||||
|
||||
var items []models.PricelistItem
|
||||
if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil {
|
||||
t.Fatalf("load pricelist items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].LotName != "CPU_X" {
|
||||
t.Fatalf("unexpected lot name: %s", items[0].LotName)
|
||||
}
|
||||
if math.Abs(items[0].Price-200) > 0.001 {
|
||||
t.Fatalf("expected weighted median price 200, got %f", items[0].Price)
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
// CalculateMedian returns the median of prices
|
||||
func CalculateMedian(prices []float64) float64 {
|
||||
if len(prices) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
sorted := make([]float64, len(prices))
|
||||
copy(sorted, prices)
|
||||
sort.Float64s(sorted)
|
||||
|
||||
n := len(sorted)
|
||||
if n%2 == 0 {
|
||||
return (sorted[n/2-1] + sorted[n/2]) / 2
|
||||
}
|
||||
return sorted[n/2]
|
||||
}
|
||||
|
||||
// CalculateAverage returns the arithmetic mean of prices
|
||||
func CalculateAverage(prices []float64) float64 {
|
||||
if len(prices) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var sum float64
|
||||
for _, p := range prices {
|
||||
sum += p
|
||||
}
|
||||
return sum / float64(len(prices))
|
||||
}
|
||||
|
||||
// CalculateWeightedMedian calculates median with exponential decay weights
|
||||
// More recent prices have higher weight
|
||||
func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 {
|
||||
if len(points) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type weightedPrice struct {
|
||||
price float64
|
||||
weight float64
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
weighted := make([]weightedPrice, len(points))
|
||||
var totalWeight float64
|
||||
|
||||
for i, p := range points {
|
||||
daysSince := now.Sub(p.Date).Hours() / 24
|
||||
// weight = e^(-days / decay_days)
|
||||
weight := math.Exp(-daysSince / float64(decayDays))
|
||||
weighted[i] = weightedPrice{price: p.Price, weight: weight}
|
||||
totalWeight += weight
|
||||
}
|
||||
|
||||
// Sort by price
|
||||
sort.Slice(weighted, func(i, j int) bool {
|
||||
return weighted[i].price < weighted[j].price
|
||||
})
|
||||
|
||||
// Find weighted median
|
||||
targetWeight := totalWeight / 2
|
||||
var cumulativeWeight float64
|
||||
|
||||
for _, wp := range weighted {
|
||||
cumulativeWeight += wp.weight
|
||||
if cumulativeWeight >= targetWeight {
|
||||
return wp.price
|
||||
}
|
||||
}
|
||||
|
||||
return weighted[len(weighted)-1].price
|
||||
}
|
||||
|
||||
// CalculatePercentile calculates the nth percentile of prices
|
||||
func CalculatePercentile(prices []float64, percentile float64) float64 {
|
||||
if len(prices) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
sorted := make([]float64, len(prices))
|
||||
copy(sorted, prices)
|
||||
sort.Float64s(sorted)
|
||||
|
||||
index := (percentile / 100) * float64(len(sorted)-1)
|
||||
lower := int(math.Floor(index))
|
||||
upper := int(math.Ceil(index))
|
||||
|
||||
if lower == upper {
|
||||
return sorted[lower]
|
||||
}
|
||||
|
||||
fraction := index - float64(lower)
|
||||
return sorted[lower]*(1-fraction) + sorted[upper]*fraction
|
||||
}
|
||||
|
||||
// CalculateStdDev calculates standard deviation
|
||||
func CalculateStdDev(prices []float64) float64 {
|
||||
if len(prices) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
mean := CalculateAverage(prices)
|
||||
var sumSquares float64
|
||||
|
||||
for _, p := range prices {
|
||||
diff := p - mean
|
||||
sumSquares += diff * diff
|
||||
}
|
||||
|
||||
return math.Sqrt(sumSquares / float64(len(prices)-1))
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
priceRepo *repository.PriceRepository
|
||||
config config.PricingConfig
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type RecalculateProgress struct {
|
||||
Current int
|
||||
Total int
|
||||
LotName string
|
||||
Updated int
|
||||
Errors int
|
||||
}
|
||||
|
||||
func NewService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
priceRepo *repository.PriceRepository,
|
||||
cfg config.PricingConfig,
|
||||
) *Service {
|
||||
var db *gorm.DB
|
||||
if componentRepo != nil {
|
||||
db = componentRepo.DB()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
componentRepo: componentRepo,
|
||||
priceRepo: priceRepo,
|
||||
config: cfg,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetEffectivePrice returns the current effective price for a component
|
||||
// Priority: active override > calculated price > nil
|
||||
func (s *Service) GetEffectivePrice(lotName string) (*float64, error) {
|
||||
// Check for active override first
|
||||
override, err := s.priceRepo.GetPriceOverride(lotName)
|
||||
if err == nil && override != nil {
|
||||
return &override.Price, nil
|
||||
}
|
||||
|
||||
// Get component metadata
|
||||
component, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return component.CurrentPrice, nil
|
||||
}
|
||||
|
||||
// CalculatePrice calculates price using the specified method
|
||||
func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) {
|
||||
if periodDays == 0 {
|
||||
periodDays = s.config.DefaultPeriodDays
|
||||
}
|
||||
|
||||
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(points) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
prices := make([]float64, len(points))
|
||||
for i, p := range points {
|
||||
prices[i] = p.Price
|
||||
}
|
||||
|
||||
switch method {
|
||||
case models.PriceMethodAverage:
|
||||
return CalculateAverage(prices), nil
|
||||
case models.PriceMethodWeightedMedian:
|
||||
return CalculateWeightedMedian(points, periodDays), nil
|
||||
case models.PriceMethodMedian:
|
||||
fallthrough
|
||||
default:
|
||||
return CalculateMedian(prices), nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateComponentPrice recalculates and updates the price for a component
|
||||
func (s *Service) UpdateComponentPrice(lotName string) error {
|
||||
component, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if price > 0 {
|
||||
component.CurrentPrice = &price
|
||||
component.PriceUpdatedAt = &now
|
||||
}
|
||||
|
||||
return s.componentRepo.Update(component)
|
||||
}
|
||||
|
||||
// SetManualPrice sets a manual price override
|
||||
func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error {
|
||||
override := &models.PriceOverride{
|
||||
LotName: lotName,
|
||||
Price: price,
|
||||
ValidFrom: time.Now(),
|
||||
Reason: reason,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
return s.priceRepo.CreatePriceOverride(override)
|
||||
}
|
||||
|
||||
// UpdatePriceMethod changes the pricing method for a component
|
||||
func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error {
|
||||
component, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
component.PriceMethod = method
|
||||
if periodDays > 0 {
|
||||
component.PricePeriodDays = periodDays
|
||||
}
|
||||
|
||||
if err := s.componentRepo.Update(component); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.UpdateComponentPrice(lotName)
|
||||
}
|
||||
|
||||
// GetPriceStats returns statistics for a component's price history
|
||||
func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) {
|
||||
if periodDays == 0 {
|
||||
periodDays = s.config.DefaultPeriodDays
|
||||
}
|
||||
|
||||
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(points) == 0 {
|
||||
return &PriceStats{QuoteCount: 0}, nil
|
||||
}
|
||||
|
||||
prices := make([]float64, len(points))
|
||||
for i, p := range points {
|
||||
prices[i] = p.Price
|
||||
}
|
||||
|
||||
return &PriceStats{
|
||||
QuoteCount: len(points),
|
||||
MinPrice: CalculatePercentile(prices, 0),
|
||||
MaxPrice: CalculatePercentile(prices, 100),
|
||||
MedianPrice: CalculateMedian(prices),
|
||||
AveragePrice: CalculateAverage(prices),
|
||||
StdDeviation: CalculateStdDev(prices),
|
||||
LatestPrice: points[0].Price,
|
||||
LatestDate: points[0].Date,
|
||||
OldestDate: points[len(points)-1].Date,
|
||||
Percentile25: CalculatePercentile(prices, 25),
|
||||
Percentile75: CalculatePercentile(prices, 75),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type PriceStats struct {
|
||||
QuoteCount int `json:"quote_count"`
|
||||
MinPrice float64 `json:"min_price"`
|
||||
MaxPrice float64 `json:"max_price"`
|
||||
MedianPrice float64 `json:"median_price"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
StdDeviation float64 `json:"std_deviation"`
|
||||
LatestPrice float64 `json:"latest_price"`
|
||||
LatestDate time.Time `json:"latest_date"`
|
||||
OldestDate time.Time `json:"oldest_date"`
|
||||
Percentile25 float64 `json:"percentile_25"`
|
||||
Percentile75 float64 `json:"percentile_75"`
|
||||
}
|
||||
|
||||
// RecalculateAllPrices recalculates prices for all components
|
||||
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
|
||||
return s.RecalculateAllPricesWithProgress(nil)
|
||||
}
|
||||
|
||||
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
|
||||
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
|
||||
if s.db == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Logic mirrors "Обновить цены" in admin pricing.
|
||||
var components []models.LotMetadata
|
||||
if err := s.db.Find(&components).Error; err != nil {
|
||||
return 0, len(components)
|
||||
}
|
||||
total := len(components)
|
||||
|
||||
var allLotNames []string
|
||||
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
|
||||
|
||||
type lotDate struct {
|
||||
Lot string
|
||||
Date time.Time
|
||||
}
|
||||
var latestDates []lotDate
|
||||
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
|
||||
lotLatestDate := make(map[string]time.Time, len(latestDates))
|
||||
for _, ld := range latestDates {
|
||||
lotLatestDate[ld.Lot] = ld.Date
|
||||
}
|
||||
|
||||
var skipped, manual, unchanged int
|
||||
now := time.Now()
|
||||
current := 0
|
||||
|
||||
for _, comp := range components {
|
||||
current++
|
||||
reportProgress := func() {
|
||||
if onProgress != nil && (current%10 == 0 || current == total) {
|
||||
onProgress(RecalculateProgress{
|
||||
Current: current,
|
||||
Total: total,
|
||||
LotName: comp.LotName,
|
||||
Updated: updated,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
|
||||
manual++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
method := comp.PriceMethod
|
||||
if method == "" {
|
||||
method = models.PriceMethodMedian
|
||||
}
|
||||
|
||||
var sourceLots []string
|
||||
if comp.MetaPrices != "" {
|
||||
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
|
||||
} else {
|
||||
sourceLots = []string{comp.LotName}
|
||||
}
|
||||
if len(sourceLots) == 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
if comp.PriceUpdatedAt != nil {
|
||||
hasNewData := false
|
||||
for _, lot := range sourceLots {
|
||||
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
|
||||
hasNewData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNewData {
|
||||
unchanged++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var prices []float64
|
||||
if comp.PricePeriodDays > 0 {
|
||||
_ = s.db.Raw(
|
||||
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
|
||||
sourceLots, comp.PricePeriodDays,
|
||||
).Pluck("price", &prices).Error
|
||||
} else {
|
||||
_ = s.db.Raw(
|
||||
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
|
||||
sourceLots,
|
||||
).Pluck("price", &prices).Error
|
||||
}
|
||||
|
||||
if len(prices) == 0 && comp.PricePeriodDays > 0 {
|
||||
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
|
||||
}
|
||||
if len(prices) == 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
var basePrice float64
|
||||
switch method {
|
||||
case models.PriceMethodAverage:
|
||||
basePrice = CalculateAverage(prices)
|
||||
default:
|
||||
basePrice = CalculateMedian(prices)
|
||||
}
|
||||
if basePrice <= 0 {
|
||||
skipped++
|
||||
reportProgress()
|
||||
continue
|
||||
}
|
||||
|
||||
finalPrice := basePrice
|
||||
if comp.PriceCoefficient != 0 {
|
||||
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
|
||||
}
|
||||
|
||||
if err := s.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", comp.LotName).
|
||||
Updates(map[string]interface{}{
|
||||
"current_price": finalPrice,
|
||||
"price_updated_at": now,
|
||||
}).Error; err != nil {
|
||||
errors++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
|
||||
reportProgress()
|
||||
}
|
||||
|
||||
if onProgress != nil && total == 0 {
|
||||
onProgress(RecalculateProgress{
|
||||
Current: 0,
|
||||
Total: 0,
|
||||
LotName: "",
|
||||
Updated: updated,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
|
||||
return updated, errors
|
||||
}
|
||||
|
||||
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
|
||||
sources := strings.Split(metaPrices, ",")
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, source := range sources {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" || source == excludeLot {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(source, "*") {
|
||||
prefix := strings.TrimSuffix(source, "*")
|
||||
for _, lot := range allLotNames {
|
||||
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
|
||||
result = append(result, lot)
|
||||
seen[lot] = true
|
||||
}
|
||||
}
|
||||
} else if !seen[source] {
|
||||
result = append(result, source)
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,18 +22,22 @@ type QuoteService struct {
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService *pricing.Service
|
||||
pricingService priceResolver
|
||||
cacheMu sync.RWMutex
|
||||
priceCache map[string]cachedLotPrice
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
type priceResolver interface {
|
||||
GetEffectivePrice(lotName string) (*float64, error)
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService *pricing.Service,
|
||||
pricingService priceResolver,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
@@ -110,8 +113,53 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrEmptyQuote
|
||||
}
|
||||
|
||||
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
|
||||
if s.localDB != nil {
|
||||
result := &QuoteValidationResult{
|
||||
Valid: true,
|
||||
Items: make([]QuoteItem, 0, len(req.Items)),
|
||||
Errors: make([]string, 0),
|
||||
Warnings: make([]string, 0),
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, reqItem := range req.Items {
|
||||
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
|
||||
continue
|
||||
}
|
||||
|
||||
item := QuoteItem{
|
||||
LotName: reqItem.LotName,
|
||||
Quantity: reqItem.Quantity,
|
||||
Description: localComp.LotDescription,
|
||||
Category: localComp.Category,
|
||||
HasPrice: false,
|
||||
UnitPrice: 0,
|
||||
TotalPrice: 0,
|
||||
}
|
||||
|
||||
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
|
||||
item.UnitPrice = *localComp.CurrentPrice
|
||||
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
|
||||
item.HasPrice = true
|
||||
total += item.TotalPrice
|
||||
} else {
|
||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
result.Total = total
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if s.componentRepo == nil || s.pricingService == nil {
|
||||
return nil, errors.New("offline mode: quote calculation not available")
|
||||
return nil, errors.New("quote calculation not available")
|
||||
}
|
||||
|
||||
result := &QuoteValidationResult{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,384 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestParseMXLRows(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`MOXCEL`,
|
||||
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
||||
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru","10"}},0},6,`,
|
||||
}, "\n")
|
||||
|
||||
rows, err := parseMXLRows([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMXLRows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Article != "CPU_X" {
|
||||
t.Fatalf("unexpected article: %s", rows[0].Article)
|
||||
}
|
||||
if rows[0].Price != 125.50 {
|
||||
t.Fatalf("unexpected price: %v", rows[0].Price)
|
||||
}
|
||||
if rows[0].Qty != 10 {
|
||||
t.Fatalf("unexpected qty: %v", rows[0].Qty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`MOXCEL`,
|
||||
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
||||
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru",""}},0},6,`,
|
||||
}, "\n")
|
||||
|
||||
rows, err := parseMXLRows([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMXLRows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if !rows[0].QtyInvalid {
|
||||
t.Fatalf("expected QtyInvalid=true for empty qty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXLSXRows(t *testing.T) {
|
||||
xlsx := buildMinimalXLSX(t, []string{
|
||||
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
|
||||
}, []string{
|
||||
"Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7",
|
||||
})
|
||||
|
||||
rows, err := parseXLSXRows(xlsx)
|
||||
if err != nil {
|
||||
t.Fatalf("parseXLSXRows: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Article != "CPU_A" {
|
||||
t.Fatalf("unexpected article: %s", rows[0].Article)
|
||||
}
|
||||
if rows[0].Price != 99.25 {
|
||||
t.Fatalf("unexpected price: %v", rows[0].Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
|
||||
resolver := lotmatch.NewLotResolver(
|
||||
[]models.LotPartnumber{
|
||||
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
|
||||
{Partnumber: "pn-conflict", LotName: "LOT_A"},
|
||||
{Partnumber: "pn-conflict", LotName: "LOT_B"},
|
||||
},
|
||||
[]models.Lot{
|
||||
{LotName: "CPU_A_LONG"},
|
||||
{LotName: "CPU_A"},
|
||||
{LotName: "ABC "},
|
||||
{LotName: "ABC\t"},
|
||||
},
|
||||
)
|
||||
|
||||
lot, typ, err := resolver.Resolve("pn-1")
|
||||
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
|
||||
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||
}
|
||||
|
||||
lot, typ, err = resolver.Resolve("cpu_a")
|
||||
if err != nil || lot != "CPU_A" || typ != "article_exact" {
|
||||
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||
}
|
||||
|
||||
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
|
||||
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
|
||||
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
|
||||
}
|
||||
|
||||
_, _, err = resolver.Resolve("abx")
|
||||
if err == nil || err != lotmatch.ErrResolveNotFound {
|
||||
t.Fatalf("expected not found error, got %v", err)
|
||||
}
|
||||
|
||||
_, _, err = resolver.Resolve("pn-conflict")
|
||||
if err == nil || err != lotmatch.ErrResolveConflict {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
|
||||
t.Fatalf("automigrate stock_log: %v", err)
|
||||
}
|
||||
|
||||
existing := models.StockLog{
|
||||
Partnumber: "CPU_A",
|
||||
Date: time.Now(),
|
||||
Price: 10,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("seed stock_log: %v", err)
|
||||
}
|
||||
|
||||
svc := NewStockImportService(db, nil)
|
||||
headerOnly := []byte(strings.Join([]string{
|
||||
`MOXCEL`,
|
||||
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
|
||||
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
|
||||
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
|
||||
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
|
||||
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
|
||||
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
|
||||
}, "\n"))
|
||||
|
||||
if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", nil); err == nil {
|
||||
t.Fatalf("expected import error")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count stock_log: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected stock_log unchanged, got %d rows", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceStockLogs(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
|
||||
t.Fatalf("automigrate stock_log: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
|
||||
t.Fatalf("seed old row: %v", err)
|
||||
}
|
||||
|
||||
svc := NewStockImportService(db, nil)
|
||||
records := []models.StockLog{
|
||||
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
|
||||
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
|
||||
}
|
||||
|
||||
deleted, inserted, err := svc.replaceStockLogs(records)
|
||||
if err != nil {
|
||||
t.Fatalf("replaceStockLogs: %v", err)
|
||||
}
|
||||
if deleted != 1 || inserted != 2 {
|
||||
t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted)
|
||||
}
|
||||
|
||||
var rows []models.StockLog
|
||||
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read rows: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
|
||||
t.Fatalf("unexpected rows after replace: %#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedMedian(t *testing.T) {
|
||||
got := weightedMedian([]weightedPricePoint{
|
||||
{price: 10, weight: 1},
|
||||
{price: 20, weight: 3},
|
||||
{price: 50, weight: 1},
|
||||
})
|
||||
if got != 20 {
|
||||
t.Fatalf("expected weighted median 20, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
|
||||
got := weightedMedian([]weightedPricePoint{
|
||||
{price: 10, weight: 0},
|
||||
{price: 20, weight: 0},
|
||||
{price: 30, weight: 0},
|
||||
})
|
||||
if got != 20 {
|
||||
t.Fatalf("expected fallback median 20, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
|
||||
qty1 := 3.0
|
||||
qty2 := 1.0
|
||||
now := time.Now()
|
||||
rows := []models.StockLog{
|
||||
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
|
||||
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatalf("seed stock_log: %v", err)
|
||||
}
|
||||
|
||||
svc := NewStockImportService(db, nil)
|
||||
items, err := svc.buildWarehousePricelistItems()
|
||||
if err != nil {
|
||||
t.Fatalf("buildWarehousePricelistItems: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if items[0].LotName != "CPU_A" {
|
||||
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
|
||||
}
|
||||
if items[0].Price != 100 {
|
||||
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
mappings := []models.LotPartnumber{
|
||||
{Partnumber: "R750*", LotName: "SERVER_R750"},
|
||||
{Partnumber: "HDD-01", LotName: "HDD_01"},
|
||||
}
|
||||
if err := db.Create(&mappings).Error; err != nil {
|
||||
t.Fatalf("seed mappings: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
|
||||
t.Fatalf("seed lot: %v", err)
|
||||
}
|
||||
|
||||
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMappingMatcherFromDB: %v", err)
|
||||
}
|
||||
|
||||
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
|
||||
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
|
||||
}
|
||||
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
|
||||
t.Fatalf("expected exact match HDD_01, got %#v", got)
|
||||
}
|
||||
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
|
||||
t.Fatalf("expected no matches, got %#v", got)
|
||||
}
|
||||
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
|
||||
t.Fatalf("expected exact lot fallback, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
write := func(name, body string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("write zip entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
||||
</Types>`)
|
||||
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||
</Relationships>`)
|
||||
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheets>
|
||||
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
|
||||
</sheets>
|
||||
</workbook>`)
|
||||
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
</Relationships>`)
|
||||
|
||||
makeCell := func(ref, value string) string {
|
||||
escaped := strings.ReplaceAll(value, "&", "&")
|
||||
escaped = strings.ReplaceAll(escaped, "<", "<")
|
||||
escaped = strings.ReplaceAll(escaped, ">", ">")
|
||||
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
|
||||
}
|
||||
|
||||
cols := []string{"A", "B", "C", "D", "E", "F"}
|
||||
var headerCells, valueCells strings.Builder
|
||||
for i := 0; i < len(cols) && i < len(headers); i++ {
|
||||
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
|
||||
}
|
||||
for i := 0; i < len(cols) && i < len(values); i++ {
|
||||
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
|
||||
}
|
||||
|
||||
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
<row r="1">`+headerCells.String()+`</row>
|
||||
<row r="2">`+valueCells.String()+`</row>
|
||||
</sheetData>
|
||||
</worksheet>`)
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -71,6 +71,15 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
if readiness, err := w.service.EnsureReadinessForSync(); err != nil {
|
||||
w.logger.Warn("background sync: blocked by readiness guard",
|
||||
"error", err,
|
||||
"reason_code", readiness.ReasonCode,
|
||||
"reason_text", readiness.ReasonText,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Push pending changes first
|
||||
pushed, err := w.service.PushPendingChanges()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user