From 452811f3932c51cbcac4b06deb3da9b97e1061f8 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Tue, 2 Jun 2026 12:57:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20sync=5Flog=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=B8=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D1=80=D0=B0=D0=B9=D1=81=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20Support=20Bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена таблица sync_log (до 100 записей на тип): фиксирует каждый запуск синхронизации с типом, статусом, ошибкой, кол-вом и временем - AppendSyncLog вызывается из SyncComponents, SyncPricelists (service и handler), SyncAll и SyncComponentsIfEmpty - Bundle теперь включает sync_log.json (200 последних записей) и pricelists.json (все скачанные прайслисты, сгруппированные по source) Co-Authored-By: Claude Sonnet 4.6 --- internal/handlers/support_bundle.go | 32 +++++++++++++++++++++++++++++ internal/handlers/sync.go | 9 ++++++++ internal/localdb/localdb.go | 32 +++++++++++++++++++++++++++++ internal/localdb/models.go | 13 ++++++++++++ internal/services/sync/service.go | 4 ++++ 5 files changed, 90 insertions(+) diff --git a/internal/handlers/support_bundle.go b/internal/handlers/support_bundle.go index ce4026c..956467d 100644 --- a/internal/handlers/support_bundle.go +++ b/internal/handlers/support_bundle.go @@ -120,6 +120,38 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { // system_metrics.json writeJSON("system_metrics.json", collectSystemMetrics()) + // sync_log.json — history of sync operations + if entries, err := h.localDB.GetSyncLog(200); err == nil { + writeJSON("sync_log.json", entries) + } + + // pricelists.json — downloaded pricelists grouped by source + if pricelists, err := h.localDB.GetLocalPricelists(); err == nil { + type plEntry struct { + ServerID uint `json:"server_id"` + Source string `json:"source"` + Version string `json:"version"` + Name string `json:"name,omitempty"` + CreatedAt time.Time `json:"created_at"` + SyncedAt time.Time `json:"synced_at"` + IsUsed bool `json:"is_used"` + } + bySource := map[string][]plEntry{} + for _, pl := range pricelists { + e := plEntry{ + ServerID: pl.ServerID, + Source: pl.Source, + Version: pl.Version, + Name: pl.Name, + CreatedAt: pl.CreatedAt, + SyncedAt: pl.SyncedAt, + IsUsed: pl.IsUsed, + } + bySource[pl.Source] = append(bySource[pl.Source], e) + } + writeJSON("pricelists.json", bySource) + } + // schema_migrations.json var migrations []localdb.LocalSchemaMigration _ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index f4a6a7e..00c8444 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -191,6 +191,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { result, err := h.localDB.SyncComponents(mariaDB) if err != nil { _ = h.localDB.SetComponentSyncResult("error", err.Error(), now) + h.localDB.AppendSyncLog("components", "error", err.Error(), 0, now, time.Since(now).Milliseconds()) slog.Error("component sync failed", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -200,6 +201,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { return } _ = h.localDB.SetComponentSyncResult("ok", "", now) + h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds()) c.JSON(http.StatusOK, SyncResultResponse{ Success: true, @@ -219,6 +221,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) { startTime := time.Now() synced, err := h.syncService.SyncPricelists() if err != nil { + h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, startTime, time.Since(startTime).Milliseconds()) slog.Error("pricelist sync failed", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -227,6 +230,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) { _ = c.Error(err) return } + h.localDB.AppendSyncLog("pricelists", "ok", "", synced, startTime, time.Since(startTime).Milliseconds()) c.JSON(http.StatusOK, SyncResultResponse{ Success: true, @@ -320,6 +324,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { compResult, err := h.localDB.SyncComponents(mariaDB) if err != nil { _ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow) + h.localDB.AppendSyncLog("components", "error", err.Error(), 0, compNow, time.Since(compNow).Milliseconds()) slog.Error("component sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -329,11 +334,14 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { return } _ = h.localDB.SetComponentSyncResult("ok", "", compNow) + h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds()) componentsSynced = compResult.TotalSynced // Sync pricelists + plNow := time.Now() pricelistsSynced, err = h.syncService.SyncPricelists() if err != nil { + h.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plNow, time.Since(plNow).Milliseconds()) slog.Error("pricelist sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -344,6 +352,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { _ = c.Error(err) return } + h.localDB.AppendSyncLog("pricelists", "ok", "", pricelistsSynced, plNow, time.Since(plNow).Milliseconds()) projectsResult, err := h.syncService.ImportProjectsToLocal() if err != nil { diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 1209aa8..4652a0d 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -229,6 +229,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error { &LocalSyncGuardState{}, &PendingChange{}, &LocalPartnumberBook{}, + &SyncLogEntry{}, ) } @@ -1153,6 +1154,37 @@ func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt t }) } +const syncLogMaxPerType = 100 + +// AppendSyncLog writes a sync result and prunes old entries beyond the per-type cap. +func (l *LocalDB) AppendSyncLog(syncType, status, errorText string, syncedCount int, startedAt time.Time, durationMs int64) { + entry := SyncLogEntry{ + SyncType: syncType, + Status: status, + ErrorText: errorText, + SyncedCount: syncedCount, + StartedAt: startedAt, + DurationMs: durationMs, + } + if err := l.db.Create(&entry).Error; err != nil { + return + } + // Prune: keep only the most recent N entries for this sync_type + l.db.Exec(` + DELETE FROM sync_log + WHERE sync_type = ? AND id NOT IN ( + SELECT id FROM sync_log WHERE sync_type = ? ORDER BY started_at DESC LIMIT ? + ) + `, syncType, syncType, syncLogMaxPerType) +} + +// GetSyncLog returns the most recent sync log entries, newest first. +func (l *LocalDB) GetSyncLog(limit int) ([]SyncLogEntry, error) { + var entries []SyncLogEntry + err := l.db.Order("started_at DESC").Limit(limit).Find(&entries).Error + return entries, err +} + func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time { value, ok := l.getAppSettingValue("last_component_sync_attempt_at") if !ok { diff --git a/internal/localdb/models.go b/internal/localdb/models.go index a636e9e..77d868a 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -317,6 +317,19 @@ type VendorSpecLotMapping struct { QuantityPerPN int `json:"quantity_per_pn"` } +// SyncLogEntry records the outcome of a single sync operation for diagnostics. +type SyncLogEntry struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + SyncType string `gorm:"not null;index;size:32" json:"sync_type"` // components | pricelists | push | full + Status string `gorm:"not null;size:16" json:"status"` // ok | error | skipped + ErrorText string `gorm:"size:1000" json:"error_text,omitempty"` + SyncedCount int `gorm:"default:0" json:"synced_count"` + StartedAt time.Time `gorm:"not null;index" json:"started_at"` + DurationMs int64 `gorm:"default:0" json:"duration_ms"` +} + +func (SyncLogEntry) TableName() string { return "sync_log" } + // VendorSpec is a JSON-encodable slice of VendorSpecItem type VendorSpec []VendorSpecItem diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 76210c8..689dbc7 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -327,6 +327,7 @@ func (s *Service) NeedSync() (bool, error) { // SyncPricelists synchronizes all active pricelists from server to local SQLite func (s *Service) SyncPricelists() (int, error) { slog.Info("starting pricelist sync") + plSyncStart := time.Now() if _, err := s.EnsureReadinessForSync(); err != nil { return 0, err } @@ -335,6 +336,7 @@ func (s *Service) SyncPricelists() (int, error) { mariaDB, err := s.getDB() if err != nil { s.recordPricelistSyncFailure(err) + s.localDB.AppendSyncLog("pricelists", "error", err.Error(), 0, plSyncStart, time.Since(plSyncStart).Milliseconds()) return 0, fmt.Errorf("database not available: %w", err) } @@ -421,6 +423,7 @@ func (s *Service) SyncPricelists() (int, error) { if syncErr != nil { s.recordPricelistSyncFailure(syncErr) + s.localDB.AppendSyncLog("pricelists", "error", syncErr.Error(), synced, plSyncStart, time.Since(plSyncStart).Milliseconds()) return synced, syncErr } @@ -429,6 +432,7 @@ func (s *Service) SyncPricelists() (int, error) { 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)) return synced, nil