feat: implement background task system with notifications
- Added background task manager with goroutine execution and panic recovery - Replaced SSE streaming with background task execution for: * Price recalculation (RecalculateAll) * Stock import (ImportStockLog) * Pricelist creation (CreateWithProgress) - Implemented unified polling for task status and DB connection in frontend - Added task indicator in top bar showing running tasks count - Added toast notifications for task completion/error - Tasks automatically cleaned up after 10 minutes - Tasks show progress (0-100%) with descriptive messages - Updated handler constructors to receive task manager - Added API endpoints for task status (/api/tasks, /api/tasks/:id) Fixes issue with SSE disconnection on slow connections during long-running operations
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/priceforge/internal/dbutil"
|
||||
"git.mchus.pro/mchus/priceforge/internal/models"
|
||||
"git.mchus.pro/mchus/priceforge/internal/warehouse"
|
||||
"gorm.io/gorm"
|
||||
@@ -201,12 +202,16 @@ func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
|
||||
|
||||
// Use batch insert for better performance
|
||||
batchSize := 500
|
||||
query := dbutil.WithTimeout(r.db, 30*time.Second) // Timeout per batch
|
||||
for i := 0; i < len(items); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
|
||||
batch := items[i:end]
|
||||
if err := query.Execute(func(db *gorm.DB) error {
|
||||
return db.CreateInBatches(batch, batchSize).Error
|
||||
}); err != nil {
|
||||
return fmt.Errorf("batch inserting pricelist items: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -227,21 +232,18 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
|
||||
}
|
||||
|
||||
var items []models.PricelistItem
|
||||
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
||||
// Optimized query with JOIN to avoid N+1
|
||||
itemsQuery := r.db.Table("qt_pricelist_items AS pi").
|
||||
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
||||
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
||||
Where("pi.pricelist_id = ?", pricelistID)
|
||||
|
||||
if search != "" {
|
||||
itemsQuery = itemsQuery.Where("pi.lot_name LIKE ?", "%"+search+"%")
|
||||
}
|
||||
|
||||
// Enrich with lot descriptions
|
||||
for i := range items {
|
||||
var lot models.Lot
|
||||
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
|
||||
items[i].LotDescription = lot.LotDescription
|
||||
}
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(items[i].LotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
items[i].Category = parts[0]
|
||||
}
|
||||
if err := itemsQuery.Order("pi.lot_name").Offset(offset).Limit(limit).Scan(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
|
||||
}
|
||||
|
||||
var pl models.Pricelist
|
||||
@@ -474,3 +476,63 @@ func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
|
||||
}
|
||||
return pricelists, nil
|
||||
}
|
||||
|
||||
// StreamItemsForExport streams pricelist items in batches with optimized query (uses JOIN to avoid N+1)
|
||||
// The callback function is called for each batch of items
|
||||
func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 500
|
||||
}
|
||||
|
||||
// Check if this is a warehouse pricelist
|
||||
var pl models.Pricelist
|
||||
isWarehouse := false
|
||||
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
|
||||
isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
|
||||
}
|
||||
|
||||
offset := 0
|
||||
for {
|
||||
var items []models.PricelistItem
|
||||
|
||||
// Optimized query with JOIN to get lot descriptions and categories in one go
|
||||
err := r.db.Table("qt_pricelist_items AS pi").
|
||||
Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
|
||||
Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
|
||||
Where("pi.pricelist_id = ?", pricelistID).
|
||||
Order("pi.lot_name").
|
||||
Offset(offset).
|
||||
Limit(batchSize).
|
||||
Scan(&items).Error
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("streaming pricelist items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Enrich warehouse items with qty and partnumbers
|
||||
if isWarehouse {
|
||||
if err := r.enrichWarehouseItems(items); err != nil {
|
||||
// Log but don't fail on enrichment error
|
||||
// return fmt.Errorf("enriching warehouse items: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call callback with this batch
|
||||
if err := callback(items); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we got fewer items than batch size, we're done
|
||||
if len(items) < batchSize {
|
||||
break
|
||||
}
|
||||
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user