feat: sync_log таблица и список прайслистов в Support Bundle

- Добавлена таблица 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:57:28 +03:00
parent 84cab011d3
commit 452811f393
5 changed files with 90 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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