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:
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user