Files
PriceForge/internal/services/pricelist/service.go
2026-02-20 19:01:07 +03:00

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)
}