508 lines
16 KiB
Go
508 lines
16 KiB
Go
package pricelist
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"git.mchus.pro/mchus/priceforge/internal/repository"
|
|
"git.mchus.pro/mchus/priceforge/internal/services/pricing"
|
|
"git.mchus.pro/mchus/priceforge/internal/warehouse"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Service struct {
|
|
repo *repository.PricelistRepository
|
|
componentRepo *repository.ComponentRepository
|
|
pricingSvc *pricing.Service
|
|
db *gorm.DB
|
|
}
|
|
|
|
type CreateProgress struct {
|
|
Current int
|
|
Total int
|
|
Status string
|
|
Message string
|
|
Updated int
|
|
Errors int
|
|
LotName string
|
|
}
|
|
|
|
type CreateItemInput struct {
|
|
LotName string
|
|
Price float64
|
|
PriceMethod string
|
|
Category string
|
|
}
|
|
|
|
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
componentRepo: componentRepo,
|
|
pricingSvc: pricingSvc,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
|
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
|
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
|
|
}
|
|
|
|
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
|
|
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
|
|
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
|
|
}
|
|
|
|
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
|
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
|
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
|
|
}
|
|
|
|
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
|
|
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
|
if s.repo == nil || s.db == nil {
|
|
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
|
}
|
|
source = string(models.NormalizePricelistSource(source))
|
|
|
|
report := func(p CreateProgress) {
|
|
if onProgress != nil {
|
|
onProgress(p)
|
|
}
|
|
}
|
|
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
|
|
|
updated, errs := 0, 0
|
|
if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
|
|
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
|
|
|
|
lastReportedPercent := 1
|
|
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
|
if p.Total <= 0 {
|
|
return
|
|
}
|
|
|
|
// Detect retry phase (when current > total means we're in retry)
|
|
isRetry := p.Current > p.Total
|
|
var phaseCurrent int
|
|
var message string
|
|
|
|
if isRetry {
|
|
// Retry phase: map to 85-90%
|
|
retryProgress := float64(p.Current-p.Total) / float64(p.Current-p.Total+1)
|
|
phaseCurrent = 85 + int(retryProgress*5.0)
|
|
if phaseCurrent > 90 {
|
|
phaseCurrent = 90
|
|
}
|
|
message = "Повтор для пропущенных компонентов"
|
|
} else {
|
|
// Normal phase: map to 1-85%
|
|
phaseCurrent = 1 + int(float64(p.Current)/float64(p.Total)*84.0)
|
|
if phaseCurrent > 85 {
|
|
phaseCurrent = 85
|
|
}
|
|
message = "Обновление цен компонентов"
|
|
}
|
|
|
|
// Only send SSE event if percentage changed by at least 5% to prevent connection overload
|
|
if phaseCurrent-lastReportedPercent >= 5 || phaseCurrent == 85 || isRetry {
|
|
lastReportedPercent = phaseCurrent
|
|
report(CreateProgress{
|
|
Current: phaseCurrent,
|
|
Total: 100,
|
|
Status: "recalculating",
|
|
Message: message,
|
|
Updated: p.Updated,
|
|
Errors: p.Errors,
|
|
LotName: p.LotName,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
report(CreateProgress{Current: 91, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
|
|
|
|
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
|
|
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
|
|
const maxCreateAttempts = 5
|
|
var pricelist *models.Pricelist
|
|
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
|
version, err := s.repo.GenerateVersionBySource(source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating version: %w", err)
|
|
}
|
|
|
|
pricelist = &models.Pricelist{
|
|
Source: source,
|
|
Version: version,
|
|
CreatedBy: createdBy,
|
|
IsActive: true,
|
|
ExpiresAt: &expiresAt,
|
|
}
|
|
|
|
if err := s.repo.Create(pricelist); err != nil {
|
|
if isVersionConflictError(err) && attempt < maxCreateAttempts {
|
|
slog.Warn("pricelist version conflict, retrying",
|
|
"attempt", attempt,
|
|
"version", version,
|
|
"error", err,
|
|
)
|
|
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("creating pricelist: %w", err)
|
|
}
|
|
break
|
|
}
|
|
|
|
items := make([]models.PricelistItem, 0)
|
|
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
|
|
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
|
|
if err != nil {
|
|
_ = s.repo.Delete(pricelist.ID)
|
|
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
|
|
}
|
|
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
|
|
for _, item := range warehouseItems {
|
|
sourceItems = append(sourceItems, CreateItemInput{
|
|
LotName: item.LotName,
|
|
Price: item.Price,
|
|
PriceMethod: item.PriceMethod,
|
|
Category: item.Category,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(sourceItems) > 0 {
|
|
// For warehouse and other explicit source items - use only provided data
|
|
// DO NOT load metadata settings (price_period_days, coefficient, etc.)
|
|
|
|
// Load categories from lot table
|
|
lotNames := make([]string, 0, len(sourceItems))
|
|
for _, srcItem := range sourceItems {
|
|
if strings.TrimSpace(srcItem.LotName) != "" {
|
|
lotNames = append(lotNames, strings.TrimSpace(srcItem.LotName))
|
|
}
|
|
}
|
|
|
|
categoryMap := make(map[string]*string)
|
|
missingCategoryLots := make([]string, 0)
|
|
defaultCategory := models.DefaultLotCategoryCode
|
|
if len(lotNames) > 0 {
|
|
var lots []models.Lot
|
|
if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil {
|
|
for _, lot := range lots {
|
|
if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" {
|
|
categoryMap[lot.LotName] = &defaultCategory
|
|
missingCategoryLots = append(missingCategoryLots, lot.LotName)
|
|
continue
|
|
}
|
|
categoryMap[lot.LotName] = lot.LotCategory
|
|
}
|
|
}
|
|
}
|
|
if len(missingCategoryLots) > 0 {
|
|
ensureCategoryExists(s.db, defaultCategory)
|
|
_ = s.db.Model(&models.Lot{}).
|
|
Where("lot_name IN ?", missingCategoryLots).
|
|
Update("lot_category", defaultCategory).Error
|
|
}
|
|
for _, lotName := range lotNames {
|
|
if _, ok := categoryMap[lotName]; !ok {
|
|
categoryMap[lotName] = &defaultCategory
|
|
}
|
|
}
|
|
|
|
items = make([]models.PricelistItem, 0, len(sourceItems))
|
|
for _, srcItem := range sourceItems {
|
|
lotName := strings.TrimSpace(srcItem.LotName)
|
|
if lotName == "" || srcItem.Price <= 0 {
|
|
continue
|
|
}
|
|
items = append(items, models.PricelistItem{
|
|
PricelistID: pricelist.ID,
|
|
LotName: lotName,
|
|
LotCategory: categoryMap[lotName],
|
|
Price: srcItem.Price,
|
|
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
|
|
})
|
|
}
|
|
} else {
|
|
// Default snapshot source for estimate and backward compatibility.
|
|
type LotMetadataWithCategory struct {
|
|
models.LotMetadata
|
|
LotCategory string
|
|
}
|
|
var metadata []LotMetadataWithCategory
|
|
if err := s.db.Table("qt_lot_metadata as m").
|
|
Select("m.*, COALESCE(l.lot_category, '') as lot_category").
|
|
Joins("LEFT JOIN lot as l ON l.lot_name = m.lot_name").
|
|
Where("m.current_price IS NOT NULL AND m.current_price > 0 AND m.is_hidden = 0").
|
|
Scan(&metadata).Error; err != nil {
|
|
return nil, fmt.Errorf("getting lot metadata with categories: %w", err)
|
|
}
|
|
|
|
// Load categories from lot table for all metadata items
|
|
lotNames := make([]string, 0, len(metadata))
|
|
for _, m := range metadata {
|
|
lotNames = append(lotNames, m.LotName)
|
|
}
|
|
|
|
categoryMap := make(map[string]*string)
|
|
missingCategoryLots := make([]string, 0)
|
|
defaultCategory := models.DefaultLotCategoryCode
|
|
if len(lotNames) > 0 {
|
|
var lots []models.Lot
|
|
if err := s.db.Where("lot_name IN ?", lotNames).Find(&lots).Error; err == nil {
|
|
for _, lot := range lots {
|
|
if lot.LotCategory == nil || strings.TrimSpace(*lot.LotCategory) == "" {
|
|
categoryMap[lot.LotName] = &defaultCategory
|
|
missingCategoryLots = append(missingCategoryLots, lot.LotName)
|
|
continue
|
|
}
|
|
categoryMap[lot.LotName] = lot.LotCategory
|
|
}
|
|
}
|
|
}
|
|
if len(missingCategoryLots) > 0 {
|
|
ensureCategoryExists(s.db, defaultCategory)
|
|
_ = s.db.Model(&models.Lot{}).
|
|
Where("lot_name IN ?", missingCategoryLots).
|
|
Update("lot_category", defaultCategory).Error
|
|
}
|
|
for _, lotName := range lotNames {
|
|
if _, ok := categoryMap[lotName]; !ok {
|
|
categoryMap[lotName] = &defaultCategory
|
|
}
|
|
}
|
|
|
|
// Create pricelist items with all price settings
|
|
items = make([]models.PricelistItem, 0, len(metadata))
|
|
for _, m := range metadata {
|
|
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
|
continue
|
|
}
|
|
items = append(items, models.PricelistItem{
|
|
PricelistID: pricelist.ID,
|
|
LotName: m.LotName,
|
|
LotCategory: categoryMap[m.LotName],
|
|
Price: *m.CurrentPrice,
|
|
PriceMethod: string(m.PriceMethod),
|
|
PricePeriodDays: m.PricePeriodDays,
|
|
PriceCoefficient: m.PriceCoefficient,
|
|
ManualPrice: m.ManualPrice,
|
|
MetaPrices: m.MetaPrices,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
_ = s.repo.Delete(pricelist.ID)
|
|
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
|
|
}
|
|
|
|
if err := s.repo.CreateItems(items); err != nil {
|
|
// Clean up the pricelist if items creation fails
|
|
s.repo.Delete(pricelist.ID)
|
|
return nil, fmt.Errorf("creating pricelist items: %w", err)
|
|
}
|
|
|
|
pricelist.ItemCount = len(items)
|
|
|
|
slog.Info("pricelist created",
|
|
"id", pricelist.ID,
|
|
"version", pricelist.Version,
|
|
"items", len(items),
|
|
"created_by", createdBy,
|
|
)
|
|
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
|
|
|
|
return pricelist, nil
|
|
}
|
|
|
|
func ensureCategoryExists(db *gorm.DB, code string) {
|
|
var count int64
|
|
if err := db.Model(&models.Category{}).Where("code = ?", code).Count(&count).Error; err != nil || count > 0 {
|
|
return
|
|
}
|
|
var maxOrder int
|
|
if err := db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder).Error; err != nil {
|
|
return
|
|
}
|
|
_ = db.Create(&models.Category{
|
|
Code: code,
|
|
Name: code,
|
|
NameRu: code,
|
|
DisplayOrder: maxOrder + 1,
|
|
}).Error
|
|
}
|
|
|
|
func isVersionConflictError(err error) bool {
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
return true
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "duplicate entry") &&
|
|
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
|
|
}
|
|
|
|
// List returns pricelists with pagination
|
|
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
|
return s.ListBySource(page, perPage, "")
|
|
}
|
|
|
|
// ListBySource returns pricelists with optional source filter.
|
|
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
|
// If no database connection (offline mode), return empty list
|
|
if s.repo == nil {
|
|
return []models.PricelistSummary{}, 0, nil
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
return s.repo.ListBySource(source, offset, perPage)
|
|
}
|
|
|
|
// ListActive returns active pricelists with pagination.
|
|
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
|
return s.ListActiveBySource(page, perPage, "")
|
|
}
|
|
|
|
// ListActiveBySource returns active pricelists with optional source filter.
|
|
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
|
if s.repo == nil {
|
|
return []models.PricelistSummary{}, 0, nil
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 20
|
|
}
|
|
offset := (page - 1) * perPage
|
|
return s.repo.ListActiveBySource(source, offset, perPage)
|
|
}
|
|
|
|
// GetByID returns a pricelist by ID
|
|
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
|
if s.repo == nil {
|
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
|
}
|
|
return s.repo.GetByID(id)
|
|
}
|
|
|
|
// GetItems returns pricelist items with pagination
|
|
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
|
if s.repo == nil {
|
|
return []models.PricelistItem{}, 0, nil
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if perPage < 1 {
|
|
perPage = 50
|
|
}
|
|
offset := (page - 1) * perPage
|
|
return s.repo.GetItems(pricelistID, offset, perPage, search)
|
|
}
|
|
|
|
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
|
|
if s.repo == nil {
|
|
return []string{}, nil
|
|
}
|
|
return s.repo.GetLotNames(pricelistID)
|
|
}
|
|
|
|
// Delete deletes a pricelist by ID
|
|
func (s *Service) Delete(id uint) error {
|
|
if s.repo == nil {
|
|
return fmt.Errorf("offline mode: cannot delete pricelists")
|
|
}
|
|
return s.repo.Delete(id)
|
|
}
|
|
|
|
// SetActive toggles active state for a pricelist.
|
|
func (s *Service) SetActive(id uint, isActive bool) error {
|
|
if s.repo == nil {
|
|
return fmt.Errorf("offline mode: cannot update pricelists")
|
|
}
|
|
return s.repo.SetActive(id, isActive)
|
|
}
|
|
|
|
// GetPriceForLot returns price by pricelist/lot.
|
|
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
|
|
if s.repo == nil {
|
|
return 0, fmt.Errorf("offline mode: pricelist service not available")
|
|
}
|
|
return s.repo.GetPriceForLot(pricelistID, lotName)
|
|
}
|
|
|
|
// CanWrite returns true if the user can create pricelists
|
|
func (s *Service) CanWrite() bool {
|
|
if s.repo == nil {
|
|
return false
|
|
}
|
|
return s.repo.CanWrite()
|
|
}
|
|
|
|
// CanWriteDebug returns write permission status with debug info
|
|
func (s *Service) CanWriteDebug() (bool, string) {
|
|
if s.repo == nil {
|
|
return false, "offline mode"
|
|
}
|
|
return s.repo.CanWriteDebug()
|
|
}
|
|
|
|
// GetLatestActive returns the most recent active pricelist
|
|
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
|
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
|
}
|
|
|
|
// GetLatestActiveBySource returns the latest active pricelist for a source.
|
|
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
|
if s.repo == nil {
|
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
|
}
|
|
return s.repo.GetLatestActiveBySource(source)
|
|
}
|
|
|
|
// CleanupExpired deletes expired and unused pricelists
|
|
func (s *Service) CleanupExpired() (int, error) {
|
|
if s.repo == nil {
|
|
return 0, fmt.Errorf("offline mode: cleanup not available")
|
|
}
|
|
|
|
expired, err := s.repo.GetExpiredUnused()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
deleted := 0
|
|
for _, pl := range expired {
|
|
if err := s.repo.Delete(pl.ID); err != nil {
|
|
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
|
|
continue
|
|
}
|
|
deleted++
|
|
}
|
|
|
|
slog.Info("cleaned up expired pricelists", "deleted", deleted)
|
|
return deleted, nil
|
|
}
|
|
|
|
// StreamItemsForExport streams pricelist items in batches for efficient CSV export
|
|
func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
|
if s.repo == nil {
|
|
return fmt.Errorf("offline mode: cannot stream pricelist items")
|
|
}
|
|
return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
|
|
}
|