From 3992dbf9194a121ebd13060262db3f94df97f1f8 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Tue, 2 Jun 2026 16:18:52 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20qt=5Fpricelist=5Fsync=5Fstatus,=20lot=5Flog=20=D0=B8?= =?UTF-8?q?=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалить все записи в qt_pricelist_sync_status (RecordSyncHeartbeat и ensureUserSyncStatusTable); ListUserSyncStatuses переведён на qt_client_schema_state - Подавлять устаревший OFFLINE_UNVERIFIED_SCHEMA в UI когда соединение уже восстановлено - Удалить все обращения к lot_log: repository/price.go, сортировка quote_count в component.go, UpdatePopularityScores в stats.go, модель LotLog Co-Authored-By: Claude Sonnet 4.6 --- internal/handlers/sync.go | 14 ++-- internal/models/lot.go | 15 ---- internal/repository/component.go | 5 -- internal/repository/price.go | 124 ------------------------------ internal/repository/stats.go | 22 ------ internal/services/sync/service.go | 90 ++++------------------ internal/services/sync/worker.go | 3 - 7 files changed, 22 insertions(+), 251 deletions(-) delete mode 100644 internal/repository/price.go diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 00c8444..1c4ca99 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -238,7 +238,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) { Synced: synced, Duration: time.Since(startTime).String(), }) - h.syncService.RecordSyncHeartbeat() } // SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite. @@ -266,7 +265,6 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) { Synced: pulled, Duration: time.Since(startTime).String(), }) - h.syncService.RecordSyncHeartbeat() } // SyncAllResponse represents result of full sync @@ -399,7 +397,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { ConfigurationsSkipped: configsResult.Skipped, Duration: time.Since(startTime).String(), }) - h.syncService.RecordSyncHeartbeat() } // checkOnline checks if MariaDB is accessible @@ -432,7 +429,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) { Synced: pushed, Duration: time.Since(startTime).String(), }) - h.syncService.RecordSyncHeartbeat() } // GetPendingCount returns the number of pending changes @@ -621,9 +617,6 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) { return } - // Keep current client heartbeat fresh so app version is available in the table. - h.syncService.RecordSyncHeartbeat() - users, err := h.syncService.ListUserSyncStatuses(threshold) if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) @@ -712,6 +705,13 @@ func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness { return nil } + // OFFLINE_UNVERIFIED_SCHEMA is only valid while actually offline. + // Suppress it when the connection manager reports online so the stale + // blocked state from a previous disconnection doesn't linger in the UI. + if state.ReasonCode == "OFFLINE_UNVERIFIED_SCHEMA" && h.checkOnline() { + return nil + } + readiness := &sync.SyncReadiness{ Status: state.Status, Blocked: state.Status == sync.ReadinessBlocked, diff --git a/internal/models/lot.go b/internal/models/lot.go index bcc0561..31db4ac 100644 --- a/internal/models/lot.go +++ b/internal/models/lot.go @@ -13,21 +13,6 @@ func (Lot) TableName() string { return "lot" } -// LotLog represents existing lot_log table (READ-ONLY) -type LotLog struct { - LotLogID uint `gorm:"column:lot_log_id;primaryKey;autoIncrement"` - Lot string `gorm:"column:lot;size:255;not null"` - Supplier string `gorm:"column:supplier;size:255;not null"` - Date time.Time `gorm:"column:date;type:date;not null"` - Price float64 `gorm:"column:price;not null"` - Quality string `gorm:"column:quality;size:255"` - Comments string `gorm:"column:comments;size:15000"` -} - -func (LotLog) TableName() string { - return "lot_log" -} - // Supplier represents existing supplier table (READ-ONLY) type Supplier struct { SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"` diff --git a/internal/repository/component.go b/internal/repository/component.go index 1227c6a..3ca3fb8 100644 --- a/internal/repository/component.go +++ b/internal/repository/component.go @@ -63,11 +63,6 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([ Order("current_price " + sortDir) case "lot_name": query = query.Order("lot_name " + sortDir) - case "quote_count": - // Sort by quote count from lot_log table - query = query. - Select("qt_lot_metadata.*, (SELECT COUNT(*) FROM lot_log WHERE lot_log.lot = qt_lot_metadata.lot_name) as quote_count_sort"). - Order("quote_count_sort " + sortDir) default: // Default: sort by popularity, no price goes last query = query. diff --git a/internal/repository/price.go b/internal/repository/price.go deleted file mode 100644 index bd6b977..0000000 --- a/internal/repository/price.go +++ /dev/null @@ -1,124 +0,0 @@ -package repository - -import ( - "time" - - "git.mchus.pro/mchus/quoteforge/internal/models" - "gorm.io/gorm" -) - -type PriceRepository struct { - db *gorm.DB -} - -func NewPriceRepository(db *gorm.DB) *PriceRepository { - return &PriceRepository{db: db} -} - -type PricePoint struct { - Price float64 - Date time.Time -} - -// GetPriceHistory returns price history from lot_log for a component -func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) { - var points []PricePoint - since := time.Now().AddDate(0, 0, -periodDays) - - err := r.db.Model(&models.LotLog{}). - Select("price, date"). - Where("lot = ? AND date >= ?", lotName, since). - Order("date DESC"). - Scan(&points).Error - - return points, err -} - -// GetLatestPrice returns the most recent price for a component -func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) { - var point PricePoint - err := r.db.Model(&models.LotLog{}). - Select("price, date"). - Where("lot = ?", lotName). - Order("date DESC"). - First(&point).Error - if err != nil { - return nil, err - } - return &point, nil -} - -// GetPriceOverride returns active override for a component -func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) { - var override models.PriceOverride - today := time.Now().Truncate(24 * time.Hour) - - err := r.db. - Where("lot_name = ?", lotName). - Where("valid_from <= ?", today). - Where("valid_until IS NULL OR valid_until >= ?", today). - Order("valid_from DESC"). - First(&override).Error - - if err != nil { - return nil, err - } - return &override, nil -} - -// CreatePriceOverride creates a new price override -func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error { - return r.db.Create(override).Error -} - -// GetPriceOverrides returns all overrides for a component -func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) { - var overrides []models.PriceOverride - err := r.db. - Where("lot_name = ?", lotName). - Order("valid_from DESC"). - Find(&overrides).Error - return overrides, err -} - -// DeletePriceOverride deletes an override -func (r *PriceRepository) DeletePriceOverride(id uint) error { - return r.db.Delete(&models.PriceOverride{}, id).Error -} - -// GetQuoteCount returns the number of quotes in lot_log for a period -func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) { - var count int64 - since := time.Now().AddDate(0, 0, -periodDays) - - err := r.db.Model(&models.LotLog{}). - Where("lot = ? AND date >= ?", lotName, since). - Count(&count).Error - - return count, err -} - -// GetQuoteCounts returns quote counts for multiple lot names -func (r *PriceRepository) GetQuoteCounts(lotNames []string) (map[string]int64, error) { - type Result struct { - Lot string - Count int64 - } - var results []Result - - err := r.db.Model(&models.LotLog{}). - Select("lot, COUNT(*) as count"). - Where("lot IN ?", lotNames). - Group("lot"). - Scan(&results).Error - - if err != nil { - return nil, err - } - - counts := make(map[string]int64) - for _, r := range results { - counts[r.Lot] = r.Count - } - return counts, nil -} diff --git a/internal/repository/stats.go b/internal/repository/stats.go index df40ecd..e881c93 100644 --- a/internal/repository/stats.go +++ b/internal/repository/stats.go @@ -91,25 +91,3 @@ func (r *StatsRepository) ResetMonthlyCounters() error { Update("quotes_last_30d", 0).Error } -// UpdatePopularityScores recalculates popularity_score in qt_lot_metadata -// based on supplier quotes from lot_log table -func (r *StatsRepository) UpdatePopularityScores() error { - // Formula: popularity_score = quotes_last_30d * 3 + quotes_last_90d * 1 + quotes_total * 0.1 - // This gives more weight to recent supplier activity - return r.db.Exec(` - UPDATE qt_lot_metadata m - LEFT JOIN ( - SELECT - lot, - COUNT(*) as quotes_total, - SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as quotes_last_30d, - SUM(CASE WHEN date >= DATE_SUB(NOW(), INTERVAL 90 DAY) THEN 1 ELSE 0 END) as quotes_last_90d - FROM lot_log - GROUP BY lot - ) s ON m.lot_name = s.lot - SET m.popularity_score = COALESCE( - s.quotes_last_30d * 3 + s.quotes_last_90d * 1 + s.quotes_total * 0.1, - 0 - ) - `).Error -} diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 194db80..faac173 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -427,7 +427,6 @@ func (s *Service) SyncPricelists() (int, error) { now := time.Now() s.localDB.SetLastSyncTime(now) s.recordPricelistSyncSuccess(now) - s.RecordSyncHeartbeat() s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds()) slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) @@ -612,58 +611,29 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository. } } -// RecordSyncHeartbeat updates shared sync heartbeat for current DB user. -// Only users with write rights are expected to be able to update this table. -func (s *Service) RecordSyncHeartbeat() { - username := strings.TrimSpace(s.localDB.GetDBUser()) - if username == "" { - return - } - - mariaDB, err := s.getDB() - if err != nil || mariaDB == nil { - return - } - - if err := ensureUserSyncStatusTable(mariaDB); err != nil { - slog.Warn("sync heartbeat: failed to ensure table", "error", err) - return - } - - now := time.Now().UTC() - if err := mariaDB.Exec(` - INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - last_sync_at = VALUES(last_sync_at), - updated_at = VALUES(updated_at), - app_version = VALUES(app_version) - `, username, now, now, appmeta.Version()).Error; err != nil { - slog.Debug("sync heartbeat: skipped", "username", username, "error", err) - } -} - -// ListUserSyncStatuses returns users who have recorded sync heartbeat. +// ListUserSyncStatuses returns users who have recorded a client schema state check. func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { mariaDB, err := s.getDB() if err != nil || mariaDB == nil { return nil, ErrOffline } - if err := ensureUserSyncStatusTable(mariaDB); err != nil { - return nil, fmt.Errorf("ensure sync status table: %w", err) - } - type row struct { - Username string `gorm:"column:username"` - LastSyncAt time.Time `gorm:"column:last_sync_at"` - AppVersion string `gorm:"column:app_version"` + Username string `gorm:"column:username"` + LastCheckedAt time.Time `gorm:"column:last_checked_at"` + AppVersion string `gorm:"column:app_version"` } var rows []row if err := mariaDB.Raw(` - SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version - FROM qt_pricelist_sync_status - ORDER BY last_sync_at DESC, username ASC + SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version + FROM qt_client_schema_state s + INNER JOIN ( + SELECT username, MAX(last_checked_at) AS max_checked + FROM qt_client_schema_state + GROUP BY username + ) latest ON s.username = latest.username AND s.last_checked_at = latest.max_checked + GROUP BY s.username + ORDER BY s.last_checked_at DESC, s.username ASC `).Scan(&rows).Error; err != nil { return nil, fmt.Errorf("load sync status rows: %w", err) } @@ -683,7 +653,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn continue } - isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold + isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold if _, connected := activeUsers[username]; connected { isOnline = true delete(activeUsers, username) @@ -693,7 +663,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn result = append(result, UserSyncStatus{ Username: username, - LastSyncAt: r.LastSyncAt, + LastSyncAt: r.LastCheckedAt, AppVersion: appVersion, IsOnline: isOnline, }) @@ -747,36 +717,6 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e return users, nil } -func ensureUserSyncStatusTable(db *gorm.DB) error { - // Check if table exists instead of trying to create (avoids permission issues) - if !tableExists(db, "qt_pricelist_sync_status") { - if err := db.Exec(` - CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status ( - username VARCHAR(100) NOT NULL, - last_sync_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - app_version VARCHAR(64) NULL, - PRIMARY KEY (username), - INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at) - ) - `).Error; err != nil { - return fmt.Errorf("create qt_pricelist_sync_status table: %w", err) - } - } - - // Backward compatibility for environments where table was created without app_version. - // Only try to add column if table exists. - if tableExists(db, "qt_pricelist_sync_status") { - if err := db.Exec(` - ALTER TABLE qt_pricelist_sync_status - ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL - `).Error; err != nil { - // Log but don't fail if alter fails (column might already exist) - slog.Debug("failed to add app_version column", "error", err) - } - } - return nil -} // SyncPricelistItems synchronizes items for a specific pricelist func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index c887b61..7bc9957 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -100,8 +100,5 @@ func (w *Worker) runSync() { return } - // Mark user's sync heartbeat (used for online/offline status in UI). - w.service.RecordSyncHeartbeat() - w.logger.Info("background sync cycle completed") }