refactor: убрать qt_pricelist_sync_status, lot_log и лишние права БД

- Удалить все записи в 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 16:18:52 +03:00
parent 1de66d6f33
commit 3992dbf919
7 changed files with 22 additions and 251 deletions

View File

@@ -238,7 +238,6 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced, Synced: synced,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite. // SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
@@ -266,7 +265,6 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
Synced: pulled, Synced: pulled,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// SyncAllResponse represents result of full sync // SyncAllResponse represents result of full sync
@@ -399,7 +397,6 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
ConfigurationsSkipped: configsResult.Skipped, ConfigurationsSkipped: configsResult.Skipped,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
@@ -432,7 +429,6 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed, Synced: pushed,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// GetPendingCount returns the number of pending changes // GetPendingCount returns the number of pending changes
@@ -621,9 +617,6 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
return return
} }
// Keep current client heartbeat fresh so app version is available in the table.
h.syncService.RecordSyncHeartbeat()
users, err := h.syncService.ListUserSyncStatuses(threshold) users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil { if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err) RespondError(c, http.StatusInternalServerError, "internal server error", err)
@@ -712,6 +705,13 @@ func (h *SyncHandler) getReadinessLocal() *sync.SyncReadiness {
return nil 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{ readiness := &sync.SyncReadiness{
Status: state.Status, Status: state.Status,
Blocked: state.Status == sync.ReadinessBlocked, Blocked: state.Status == sync.ReadinessBlocked,

View File

@@ -13,21 +13,6 @@ func (Lot) TableName() string {
return "lot" 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) // Supplier represents existing supplier table (READ-ONLY)
type Supplier struct { type Supplier struct {
SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"` SupplierName string `gorm:"column:supplier_name;primaryKey;size:255"`

View File

@@ -63,11 +63,6 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
Order("current_price " + sortDir) Order("current_price " + sortDir)
case "lot_name": case "lot_name":
query = query.Order("lot_name " + sortDir) 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:
// Default: sort by popularity, no price goes last // Default: sort by popularity, no price goes last
query = query. query = query.

View File

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

View File

@@ -91,25 +91,3 @@ func (r *StatsRepository) ResetMonthlyCounters() error {
Update("quotes_last_30d", 0).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
}

View File

@@ -427,7 +427,6 @@ func (s *Service) SyncPricelists() (int, error) {
now := time.Now() now := time.Now()
s.localDB.SetLastSyncTime(now) s.localDB.SetLastSyncTime(now)
s.recordPricelistSyncSuccess(now) s.recordPricelistSyncSuccess(now)
s.RecordSyncHeartbeat()
s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds()) s.localDB.AppendSyncLog("pricelists", "ok", "", synced, plSyncStart, time.Since(plSyncStart).Milliseconds())
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) 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. // ListUserSyncStatuses returns users who have recorded a client schema state check.
// 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.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB() mariaDB, err := s.getDB()
if err != nil || mariaDB == nil { if err != nil || mariaDB == nil {
return nil, ErrOffline return nil, ErrOffline
} }
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
return nil, fmt.Errorf("ensure sync status table: %w", err)
}
type row struct { type row struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
LastSyncAt time.Time `gorm:"column:last_sync_at"` LastCheckedAt time.Time `gorm:"column:last_checked_at"`
AppVersion string `gorm:"column:app_version"` AppVersion string `gorm:"column:app_version"`
} }
var rows []row var rows []row
if err := mariaDB.Raw(` if err := mariaDB.Raw(`
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version SELECT s.username, s.last_checked_at, COALESCE(s.app_version, '') AS app_version
FROM qt_pricelist_sync_status FROM qt_client_schema_state s
ORDER BY last_sync_at DESC, username ASC 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 { `).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load sync status rows: %w", err) return nil, fmt.Errorf("load sync status rows: %w", err)
} }
@@ -683,7 +653,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
continue continue
} }
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold isOnline := now.Sub(r.LastCheckedAt) <= onlineThreshold
if _, connected := activeUsers[username]; connected { if _, connected := activeUsers[username]; connected {
isOnline = true isOnline = true
delete(activeUsers, username) delete(activeUsers, username)
@@ -693,7 +663,7 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
result = append(result, UserSyncStatus{ result = append(result, UserSyncStatus{
Username: username, Username: username,
LastSyncAt: r.LastSyncAt, LastSyncAt: r.LastCheckedAt,
AppVersion: appVersion, AppVersion: appVersion,
IsOnline: isOnline, IsOnline: isOnline,
}) })
@@ -747,36 +717,6 @@ func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, e
return users, nil 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 // SyncPricelistItems synchronizes items for a specific pricelist
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {

View File

@@ -100,8 +100,5 @@ func (w *Worker) runSync() {
return return
} }
// Mark user's sync heartbeat (used for online/offline status in UI).
w.service.RecordSyncHeartbeat()
w.logger.Info("background sync cycle completed") w.logger.Info("background sync cycle completed")
} }