From c47c93ab315c792253078377944429de53e2693a Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 10 Feb 2026 15:17:16 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B9=D1=81=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две ключевые исправления: 1. Потоковая отправка прогресса создания (SSE): - Эндпоинт CreateWithProgress теперь отправляет Server-Sent Events вместо возврата JSON с task_id - Полирует статус задачи и отправляет обновления прогресса в реальном времени - Отправляет финальное событие с данными прайслиста или ошибкой - Фронтенд уже ожидал этого формата SSE 2. Исправление маппинга колонки lot_category: - Добавлен явный тег column в поле Category модели PricelistItem чтобы маппиться на колонку 'lot_category' в БД - Категория теперь хранится как снимок в таблице pricelist_items - Обновлены запросы репозитория для использования сохраненной категории вместо динамических JOIN с таблицей lot Это исправляет ошибки: - "Создание прервано: не получен результат" (фронтенд ожидал streaming) - "Unknown column 'category' in 'INSERT INTO'" (несоответствие схемы БД) Co-Authored-By: Claude Haiku 4.5 --- internal/handlers/pricelist.go | 59 ++++++++++++++++++- internal/models/pricelist.go | 7 ++- internal/repository/pricelist.go | 8 +-- internal/services/pricelist/service.go | 43 +++++++++++++- internal/warehouse/snapshot.go | 26 +++++++- ...22_add_lot_category_to_pricelist_items.sql | 16 +++++ 6 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 migrations/022_add_lot_category_to_pricelist_items.sql diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index 371a251..3f0bc3d 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -3,12 +3,14 @@ package handlers import ( "context" "encoding/csv" + "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" + "time" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/services/pricelist" @@ -152,7 +154,62 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) { }, nil }) - c.JSON(http.StatusOK, gin.H{"task_id": taskID}) + // Stream task progress as Server-Sent Events + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + lastProgress := -1 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-c.Request.Context().Done(): + return + case <-ticker.C: + task, err := h.taskManager.Get(taskID) + if err != nil { + return + } + + // Send progress if changed + if task.Progress != lastProgress { + data, _ := json.Marshal(map[string]interface{}{ + "status": task.Status, + "current": task.Progress, + "total": 100, + "message": task.Message, + }) + fmt.Fprintf(c.Writer, "data: %s\n\n", string(data)) + c.Writer.Flush() + lastProgress = task.Progress + } + + // Check if task is done + if task.Status == tasks.TaskStatusCompleted || task.Status == tasks.TaskStatusError { + // Send final event + if task.Status == tasks.TaskStatusError { + errorData, _ := json.Marshal(map[string]interface{}{ + "status": "error", + "message": task.Error, + }) + fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData)) + } else { + // Extract pricelist from result + resultData := map[string]interface{}{ + "status": "completed", + "message": task.Message, + "pricelist": task.Result, + } + data, _ := json.Marshal(resultData) + fmt.Fprintf(c.Writer, "data: %s\n\n", string(data)) + } + c.Writer.Flush() + return + } + } + } } func (h *PricelistHandler) Delete(c *gin.Context) { diff --git a/internal/models/pricelist.go b/internal/models/pricelist.go index dcdbe3b..f931c09 100644 --- a/internal/models/pricelist.go +++ b/internal/models/pricelist.go @@ -65,12 +65,15 @@ type PricelistItem struct { MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"` // Virtual fields for display (not stored in qt_pricelist_items table) - // LotDescription and Category are populated via JOIN in SQL queries + // LotDescription is populated via JOIN in SQL queries // AvailableQty and Partnumbers are populated programmatically LotDescription string `gorm:"-" json:"lot_description,omitempty"` - Category string `gorm:"-" json:"category,omitempty"` AvailableQty *float64 `gorm:"-" json:"available_qty,omitempty"` Partnumbers []string `gorm:"-" json:"partnumbers,omitempty"` + + // Historical snapshot of category at time pricelist was created + // (not fetched dynamically from lot table) + Category string `gorm:"column:lot_category;size:50" json:"category,omitempty"` } func (PricelistItem) TableName() string { diff --git a/internal/repository/pricelist.go b/internal/repository/pricelist.go index 7b5f66f..07a7e02 100644 --- a/internal/repository/pricelist.go +++ b/internal/repository/pricelist.go @@ -232,9 +232,9 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear } var items []models.PricelistItem - // Optimized query with JOIN to avoid N+1 + // Optimized query with JOIN only for lot_description (category is now stored in pricelist_items) itemsQuery := r.db.Table("qt_pricelist_items AS pi"). - Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category"). + Select("pi.*, COALESCE(l.lot_description, '') AS lot_description"). Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name"). Where("pi.pricelist_id = ?", pricelistID) @@ -495,9 +495,9 @@ func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize i for { var items []models.PricelistItem - // Optimized query with JOIN to get lot descriptions and categories in one go + // Optimized query with JOIN only for lot_description (category is now stored in pricelist_items) err := r.db.Table("qt_pricelist_items AS pi"). - Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category"). + Select("pi.*, COALESCE(l.lot_description, '') AS lot_description"). Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name"). Where("pi.pricelist_id = ?", pricelistID). Order("pi.lot_name"). diff --git a/internal/services/pricelist/service.go b/internal/services/pricelist/service.go index cb11caf..baae141 100644 --- a/internal/services/pricelist/service.go +++ b/internal/services/pricelist/service.go @@ -35,6 +35,7 @@ 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 { @@ -170,28 +171,63 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt LotName: item.LotName, Price: item.Price, PriceMethod: item.PriceMethod, + Category: item.Category, }) } } if len(sourceItems) > 0 { + // Load categories for all lot names if not provided + lotNamesToLoad := make([]string, 0) + for _, srcItem := range sourceItems { + if strings.TrimSpace(srcItem.LotName) != "" && srcItem.Price > 0 && srcItem.Category == "" { + lotNamesToLoad = append(lotNamesToLoad, strings.TrimSpace(srcItem.LotName)) + } + } + + categoryByLot := make(map[string]string) + if len(lotNamesToLoad) > 0 { + var lotCategories []struct { + LotName string + LotCategory string + } + if err := s.db.Table("lot").Select("lot_name, lot_category").Where("lot_name IN ?", lotNamesToLoad).Scan(&lotCategories).Error; err == nil { + for _, lc := range lotCategories { + categoryByLot[lc.LotName] = lc.LotCategory + } + } + } + items = make([]models.PricelistItem, 0, len(sourceItems)) for _, srcItem := range sourceItems { if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 { continue } + category := srcItem.Category + if category == "" { + category = categoryByLot[strings.TrimSpace(srcItem.LotName)] + } items = append(items, models.PricelistItem{ PricelistID: pricelist.ID, LotName: strings.TrimSpace(srcItem.LotName), Price: srcItem.Price, PriceMethod: strings.TrimSpace(srcItem.PriceMethod), + Category: category, }) } } 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) + 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"). + Scan(&metadata).Error; err != nil { + return nil, fmt.Errorf("getting lot metadata with categories: %w", err) } // Create pricelist items with all price settings @@ -209,6 +245,7 @@ func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceIt PriceCoefficient: m.PriceCoefficient, ManualPrice: m.ManualPrice, MetaPrices: m.MetaPrices, + Category: m.LotCategory, }) } } diff --git a/internal/warehouse/snapshot.go b/internal/warehouse/snapshot.go index 350d267..9e906c7 100644 --- a/internal/warehouse/snapshot.go +++ b/internal/warehouse/snapshot.go @@ -13,6 +13,7 @@ type SnapshotItem struct { LotName string Price float64 PriceMethod string + Category string // Historical snapshot of lot_category } type weightedPricePoint struct { @@ -61,8 +62,31 @@ func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) { if price <= 0 { continue } - items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"}) + items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median", Category: ""}) } + + // Load categories for all lots in a single query + if len(items) > 0 { + lotNames := make([]string, 0, len(items)) + lotToIdx := make(map[string]int, len(items)) + for i, item := range items { + lotToIdx[item.LotName] = i + lotNames = append(lotNames, item.LotName) + } + + var categories []struct { + LotName string + LotCategory string + } + if err := db.Table("lot").Select("lot_name, lot_category").Where("lot_name IN ?", lotNames).Scan(&categories).Error; err == nil { + for _, cat := range categories { + if idx, ok := lotToIdx[cat.LotName]; ok { + items[idx].Category = cat.LotCategory + } + } + } + } + sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName }) return items, nil } diff --git a/migrations/022_add_lot_category_to_pricelist_items.sql b/migrations/022_add_lot_category_to_pricelist_items.sql new file mode 100644 index 0000000..a2c3d11 --- /dev/null +++ b/migrations/022_add_lot_category_to_pricelist_items.sql @@ -0,0 +1,16 @@ +-- Add lot_category column to qt_pricelist_items +-- This captures a historical snapshot of the lot category at the time the pricelist was created +-- rather than dynamically fetching it from the lot table (which could change) + +ALTER TABLE qt_pricelist_items + ADD COLUMN IF NOT EXISTS lot_category VARCHAR(50) NULL AFTER lot_name; + +-- Backfill existing records with category from lot table +UPDATE qt_pricelist_items pi +INNER JOIN lot l ON l.lot_name = pi.lot_name +SET pi.lot_category = l.lot_category +WHERE pi.lot_category IS NULL AND l.lot_category IS NOT NULL; + +-- Create index for efficient filtering by category (for future use) +CREATE INDEX IF NOT EXISTS idx_qt_pricelist_items_category + ON qt_pricelist_items(lot_category);