- 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
404 lines
11 KiB
Go
404 lines
11 KiB
Go
package localdb
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ComponentFilter for searching with filters
|
|
type ComponentFilter struct {
|
|
Category string
|
|
Search string
|
|
HasPrice bool
|
|
}
|
|
|
|
// ComponentSyncResult contains statistics from component sync
|
|
type ComponentSyncResult struct {
|
|
TotalSynced int
|
|
NewCount int
|
|
UpdateCount int
|
|
Duration time.Duration
|
|
}
|
|
|
|
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
|
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
|
startTime := time.Now()
|
|
|
|
// Query to join lot with qt_lot_metadata
|
|
// Use LEFT JOIN to include lots without metadata
|
|
type componentRow struct {
|
|
LotName string
|
|
LotDescription string
|
|
Category *string
|
|
Model *string
|
|
CurrentPrice *float64
|
|
}
|
|
|
|
var rows []componentRow
|
|
err := mariaDB.Raw(`
|
|
SELECT
|
|
l.lot_name,
|
|
l.lot_description,
|
|
COALESCE(l.lot_category, c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
|
m.model,
|
|
m.current_price
|
|
FROM lot l
|
|
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
|
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
|
ORDER BY l.lot_name
|
|
`).Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
slog.Warn("no components found in MariaDB")
|
|
return &ComponentSyncResult{
|
|
Duration: time.Since(startTime),
|
|
}, nil
|
|
}
|
|
|
|
// Get existing local components for comparison
|
|
existingMap := make(map[string]bool)
|
|
var existing []LocalComponent
|
|
if err := l.db.Find(&existing).Error; err != nil {
|
|
return nil, fmt.Errorf("reading existing local components: %w", err)
|
|
}
|
|
for _, c := range existing {
|
|
existingMap[c.LotName] = true
|
|
}
|
|
|
|
// Prepare components for batch insert/update
|
|
syncTime := time.Now()
|
|
components := make([]LocalComponent, 0, len(rows))
|
|
newCount := 0
|
|
|
|
for _, row := range rows {
|
|
category := ""
|
|
if row.Category != nil {
|
|
category = *row.Category
|
|
}
|
|
|
|
model := ""
|
|
if row.Model != nil {
|
|
model = *row.Model
|
|
}
|
|
|
|
comp := LocalComponent{
|
|
LotName: row.LotName,
|
|
LotDescription: row.LotDescription,
|
|
Category: category,
|
|
Model: model,
|
|
CurrentPrice: row.CurrentPrice,
|
|
SyncedAt: syncTime,
|
|
}
|
|
components = append(components, comp)
|
|
|
|
if !existingMap[row.LotName] {
|
|
newCount++
|
|
}
|
|
}
|
|
|
|
// Use transaction for bulk upsert
|
|
err = l.db.Transaction(func(tx *gorm.DB) error {
|
|
// Delete all existing and insert new (simpler than upsert for SQLite)
|
|
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
|
|
return fmt.Errorf("clearing local components: %w", err)
|
|
}
|
|
|
|
// Batch insert
|
|
batchSize := 500
|
|
for i := 0; i < len(components); i += batchSize {
|
|
end := i + batchSize
|
|
if end > len(components) {
|
|
end = len(components)
|
|
}
|
|
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
|
|
return fmt.Errorf("inserting components batch: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update last sync time
|
|
if err := l.SetComponentSyncTime(syncTime); err != nil {
|
|
slog.Warn("failed to update component sync time", "error", err)
|
|
}
|
|
|
|
result := &ComponentSyncResult{
|
|
TotalSynced: len(components),
|
|
NewCount: newCount,
|
|
UpdateCount: len(components) - newCount,
|
|
Duration: time.Since(startTime),
|
|
}
|
|
|
|
slog.Info("components synced",
|
|
"total", result.TotalSynced,
|
|
"new", result.NewCount,
|
|
"updated", result.UpdateCount,
|
|
"duration", result.Duration)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// SearchLocalComponents searches components in local cache by query string
|
|
// Searches in lot_name, lot_description, category, and model fields
|
|
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
|
|
var components []LocalComponent
|
|
|
|
if query == "" {
|
|
// Return all components with limit
|
|
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
|
|
return components, err
|
|
}
|
|
|
|
// Search with LIKE on multiple fields
|
|
searchPattern := "%" + strings.ToLower(query) + "%"
|
|
|
|
err := l.db.Where(
|
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
|
searchPattern, searchPattern, searchPattern, searchPattern,
|
|
).Order("lot_name").Limit(limit).Find(&components).Error
|
|
|
|
return components, err
|
|
}
|
|
|
|
// SearchLocalComponentsByCategory searches components by category and optional query
|
|
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
|
|
var components []LocalComponent
|
|
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
|
|
|
|
if query != "" {
|
|
searchPattern := "%" + strings.ToLower(query) + "%"
|
|
db = db.Where(
|
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
|
|
searchPattern, searchPattern, searchPattern,
|
|
)
|
|
}
|
|
|
|
err := db.Order("lot_name").Limit(limit).Find(&components).Error
|
|
return components, err
|
|
}
|
|
|
|
// ListComponents returns components with filtering and pagination
|
|
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
|
db := l.db
|
|
|
|
// Apply category filter
|
|
if filter.Category != "" {
|
|
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
|
}
|
|
|
|
// Apply search filter
|
|
if filter.Search != "" {
|
|
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
|
db = db.Where(
|
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
|
searchPattern, searchPattern, searchPattern, searchPattern,
|
|
)
|
|
}
|
|
|
|
// Apply price filter
|
|
if filter.HasPrice {
|
|
db = db.Where("current_price IS NOT NULL")
|
|
}
|
|
|
|
// Get total count
|
|
var total int64
|
|
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Apply pagination and get results
|
|
var components []LocalComponent
|
|
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return components, total, nil
|
|
}
|
|
|
|
// GetLocalComponent returns a single component by lot_name
|
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|
var component LocalComponent
|
|
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &component, nil
|
|
}
|
|
|
|
// GetLocalComponentCategories returns distinct categories from local components
|
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
|
var categories []string
|
|
err := l.db.Model(&LocalComponent{}).
|
|
Distinct("category").
|
|
Where("category != ''").
|
|
Order("category").
|
|
Pluck("category", &categories).Error
|
|
return categories, err
|
|
}
|
|
|
|
// CountLocalComponents returns the total number of local components
|
|
func (l *LocalDB) CountLocalComponents() int64 {
|
|
var count int64
|
|
l.db.Model(&LocalComponent{}).Count(&count)
|
|
return count
|
|
}
|
|
|
|
// CountLocalComponentsByCategory returns component count by category
|
|
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
|
|
var count int64
|
|
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
|
|
return count
|
|
}
|
|
|
|
// GetComponentSyncTime returns the last component sync timestamp
|
|
func (l *LocalDB) GetComponentSyncTime() *time.Time {
|
|
var setting struct {
|
|
Value string
|
|
}
|
|
if err := l.db.Table("app_settings").
|
|
Where("key = ?", "last_component_sync").
|
|
First(&setting).Error; err != nil {
|
|
return nil
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, setting.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
// SetComponentSyncTime sets the last component sync timestamp
|
|
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
|
|
return l.db.Exec(`
|
|
INSERT INTO app_settings (key, value, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
|
|
}
|
|
|
|
// NeedComponentSync checks if component sync is needed (older than specified hours)
|
|
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|
syncTime := l.GetComponentSyncTime()
|
|
if syncTime == nil {
|
|
return true
|
|
}
|
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
|
}
|
|
|
|
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
|
// This allows offline price updates using synced pricelists without MariaDB connection
|
|
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
|
// Get all items from the specified pricelist
|
|
var items []LocalPricelistItem
|
|
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
|
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
|
return 0, nil
|
|
}
|
|
|
|
// Update current_price for each component
|
|
updated := 0
|
|
err := l.db.Transaction(func(tx *gorm.DB) error {
|
|
for _, item := range items {
|
|
result := tx.Model(&LocalComponent{}).
|
|
Where("lot_name = ?", item.LotName).
|
|
Update("current_price", item.Price)
|
|
|
|
if result.Error != nil {
|
|
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
|
}
|
|
|
|
if result.RowsAffected > 0 {
|
|
updated++
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
slog.Info("updated component prices from pricelist",
|
|
"pricelist_id", pricelistID,
|
|
"total_items", len(items),
|
|
"updated_components", updated)
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
|
// if no components exist or all current prices are NULL
|
|
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
|
// Check if we have any components with prices
|
|
var count int64
|
|
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
|
return fmt.Errorf("checking component prices: %w", err)
|
|
}
|
|
|
|
// If we have components with prices, don't load from pricelists
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Check if we have any components at all
|
|
var totalComponents int64
|
|
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
|
return fmt.Errorf("counting components: %w", err)
|
|
}
|
|
|
|
// If we have no components, we need to load them from pricelists
|
|
if totalComponents == 0 {
|
|
slog.Info("no components found in local database, loading from latest pricelist")
|
|
// This would typically be called from the sync service or setup process
|
|
// For now, we'll just return nil to indicate no action needed
|
|
return nil
|
|
}
|
|
|
|
// If we have components but no prices, load from latest estimate pricelist.
|
|
var latestPricelist LocalPricelist
|
|
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
slog.Warn("no pricelists found in local database")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("finding latest pricelist: %w", err)
|
|
}
|
|
|
|
// Update prices from the latest pricelist
|
|
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
|
}
|
|
|
|
slog.Info("loaded component prices from latest pricelist",
|
|
"pricelist_id", latestPricelist.ID,
|
|
"updated_components", updated)
|
|
|
|
return nil
|
|
}
|