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:
2026-02-08 20:39:59 +03:00
parent 06aa7c7067
commit e97cd5048c
15 changed files with 1080 additions and 555 deletions

View File

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