From 68cd087356387d5a10e86e6b88b0c7c82f730efb Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 17 Mar 2026 12:05:02 +0300 Subject: [PATCH] Fix incomplete pricelist sync status --- bible-local/02-architecture.md | 2 + internal/handlers/sync.go | 142 +++++++++++---- internal/localdb/localdb.go | 69 +++++++ internal/services/sync/service.go | 171 +++++++++++++----- .../sync/service_pricelist_cleanup_test.go | 58 ++++++ web/templates/base.html | 46 +++++ web/templates/index.html | 2 +- web/templates/partials/sync_status.html | 16 +- 8 files changed, 427 insertions(+), 79 deletions(-) diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 4fc9558..d0a3e35 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -35,6 +35,8 @@ Readiness guard: - blocked sync returns `423 Locked` with a machine-readable reason; - local work continues even when sync is blocked. - sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp. +- pricelist pull must persist a new local snapshot atomically: header and items appear together, and `last_pricelist_sync` advances only after item download succeeds. +- UI sync status must distinguish "last sync failed" from "up to date"; if the app can prove newer server pricelist data exists, the indicator must say local cache is incomplete. ## Pricing contract diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index af398c4..b0e5eed 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -6,6 +6,7 @@ import ( "html/template" "log/slog" "net/http" + "strings" stdsync "sync" "time" @@ -49,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr // SyncStatusResponse represents the sync status type SyncStatusResponse struct { - LastComponentSync *time.Time `json:"last_component_sync"` - LastPricelistSync *time.Time `json:"last_pricelist_sync"` - IsOnline bool `json:"is_online"` - ComponentsCount int64 `json:"components_count"` - PricelistsCount int64 `json:"pricelists_count"` - ServerPricelists int `json:"server_pricelists"` - NeedComponentSync bool `json:"need_component_sync"` - NeedPricelistSync bool `json:"need_pricelist_sync"` - Readiness *sync.SyncReadiness `json:"readiness,omitempty"` + LastComponentSync *time.Time `json:"last_component_sync"` + LastPricelistSync *time.Time `json:"last_pricelist_sync"` + LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"` + LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"` + LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"` + HasIncompleteServerSync bool `json:"has_incomplete_server_sync"` + KnownServerChangesMiss bool `json:"known_server_changes_missing"` + IsOnline bool `json:"is_online"` + ComponentsCount int64 `json:"components_count"` + PricelistsCount int64 `json:"pricelists_count"` + ServerPricelists int `json:"server_pricelists"` + NeedComponentSync bool `json:"need_component_sync"` + NeedPricelistSync bool `json:"need_pricelist_sync"` + Readiness *sync.SyncReadiness `json:"readiness,omitempty"` } type SyncReadinessResponse struct { @@ -86,11 +92,21 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { // Get server pricelist count if online serverPricelists := 0 needPricelistSync := false + lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() + lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() + lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() + hasIncompleteServerSync := false + knownServerChangesMissing := false if isOnline { status, err := h.syncService.GetStatus() if err == nil { serverPricelists = status.ServerPricelists needPricelistSync = status.NeedsSync + lastPricelistAttemptAt = status.LastAttemptAt + lastPricelistSyncStatus = status.LastSyncStatus + lastPricelistSyncError = status.LastSyncError + hasIncompleteServerSync = status.IncompleteServerSync + knownServerChangesMissing = status.KnownServerChangesMiss } } @@ -99,15 +115,20 @@ func (h *SyncHandler) GetStatus(c *gin.Context) { readiness := h.getReadinessCached(10 * time.Second) c.JSON(http.StatusOK, SyncStatusResponse{ - LastComponentSync: lastComponentSync, - LastPricelistSync: lastPricelistSync, - IsOnline: isOnline, - ComponentsCount: componentsCount, - PricelistsCount: pricelistsCount, - ServerPricelists: serverPricelists, - NeedComponentSync: needComponentSync, - NeedPricelistSync: needPricelistSync, - Readiness: readiness, + LastComponentSync: lastComponentSync, + LastPricelistSync: lastPricelistSync, + LastPricelistAttemptAt: lastPricelistAttemptAt, + LastPricelistSyncStatus: lastPricelistSyncStatus, + LastPricelistSyncError: lastPricelistSyncError, + HasIncompleteServerSync: hasIncompleteServerSync, + KnownServerChangesMiss: knownServerChangesMissing, + IsOnline: isOnline, + ComponentsCount: componentsCount, + PricelistsCount: pricelistsCount, + ServerPricelists: serverPricelists, + NeedComponentSync: needComponentSync, + NeedPricelistSync: needPricelistSync, + Readiness: readiness, }) } @@ -474,8 +495,13 @@ type SyncInfoResponse struct { DBName string `json:"db_name"` // Status - IsOnline bool `json:"is_online"` - LastSyncAt *time.Time `json:"last_sync_at"` + IsOnline bool `json:"is_online"` + LastSyncAt *time.Time `json:"last_sync_at"` + LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"` + LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"` + LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"` + NeedPricelistSync bool `json:"need_pricelist_sync"` + HasIncompleteServerSync bool `json:"has_incomplete_server_sync"` // Statistics LotCount int64 `json:"lot_count"` @@ -524,6 +550,11 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { // Get sync times lastPricelistSync := h.localDB.GetLastSyncTime() + lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt() + lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() + lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() + needPricelistSync := false + hasIncompleteServerSync := false // Get local counts configCount := h.localDB.CountConfigurations() @@ -557,21 +588,35 @@ func (h *SyncHandler) GetInfo(c *gin.Context) { } readiness := h.getReadinessCached(10 * time.Second) + if isOnline { + if status, err := h.syncService.GetStatus(); err == nil { + lastPricelistAttemptAt = status.LastAttemptAt + lastPricelistSyncStatus = status.LastSyncStatus + lastPricelistSyncError = status.LastSyncError + needPricelistSync = status.NeedsSync + hasIncompleteServerSync = status.IncompleteServerSync + } + } c.JSON(http.StatusOK, SyncInfoResponse{ - DBHost: dbHost, - DBUser: dbUser, - DBName: dbName, - IsOnline: isOnline, - LastSyncAt: lastPricelistSync, - LotCount: componentCount, - LotLogCount: pricelistCount, - ConfigCount: configCount, - ProjectCount: projectCount, - PendingChanges: changes, - ErrorCount: errorCount, - Errors: syncErrors, - Readiness: readiness, + DBHost: dbHost, + DBUser: dbUser, + DBName: dbName, + IsOnline: isOnline, + LastSyncAt: lastPricelistSync, + LastPricelistAttemptAt: lastPricelistAttemptAt, + LastPricelistSyncStatus: lastPricelistSyncStatus, + LastPricelistSyncError: lastPricelistSyncError, + NeedPricelistSync: needPricelistSync, + HasIncompleteServerSync: hasIncompleteServerSync, + LotCount: componentCount, + LotLogCount: pricelistCount, + ConfigCount: configCount, + ProjectCount: projectCount, + PendingChanges: changes, + ErrorCount: errorCount, + Errors: syncErrors, + Readiness: readiness, }) } @@ -628,13 +673,38 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { pendingCount := h.localDB.GetPendingCount() readiness := h.getReadinessCached(10 * time.Second) isBlocked := readiness != nil && readiness.Blocked + lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus() + lastPricelistSyncError := h.localDB.GetLastPricelistSyncError() + hasIncompleteServerSync := false + if !isOffline { + if status, err := h.syncService.GetStatus(); err == nil { + lastPricelistSyncStatus = status.LastSyncStatus + lastPricelistSyncError = status.LastSyncError + hasIncompleteServerSync = status.IncompleteServerSync + } + } + hasFailedSync := strings.EqualFold(lastPricelistSyncStatus, "failed") slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked) data := gin.H{ - "IsOffline": isOffline, - "PendingCount": pendingCount, - "IsBlocked": isBlocked, + "IsOffline": isOffline, + "PendingCount": pendingCount, + "IsBlocked": isBlocked, + "HasFailedSync": hasFailedSync, + "HasIncompleteServerSync": hasIncompleteServerSync, + "SyncIssueTitle": func() string { + if hasIncompleteServerSync { + return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально." + } + if hasFailedSync { + if lastPricelistSyncError != "" { + return lastPricelistSyncError + } + return "Последняя синхронизация прайслистов завершилась ошибкой." + } + return "" + }(), "BlockedReason": func() string { if readiness == nil { return "" diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 3862d15..5c3a608 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1066,6 +1066,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time { return &t } +func (l *LocalDB) getAppSettingValue(key string) (string, bool) { + var setting struct { + Value string + } + if err := l.db.Table("app_settings"). + Where("key = ?", key). + First(&setting).Error; err != nil { + return "", false + } + return setting.Value, true +} + +func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error { + return tx.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, key, value, updatedAt.Format(time.RFC3339)).Error +} + // SetLastSyncTime sets the last sync timestamp func (l *LocalDB) SetLastSyncTime(t time.Time) error { // Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions @@ -1076,6 +1096,55 @@ func (l *LocalDB) SetLastSyncTime(t time.Time) error { `, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error } +func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time { + value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at") + if !ok { + return nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil + } + return &t +} + +func (l *LocalDB) GetLastPricelistSyncStatus() string { + value, ok := l.getAppSettingValue("last_pricelist_sync_status") + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func (l *LocalDB) GetLastPricelistSyncError() string { + value, ok := l.getAppSettingValue("last_pricelist_sync_error") + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error { + status = strings.TrimSpace(status) + errorText = strings.TrimSpace(errorText) + if status == "" { + status = "unknown" + } + + return l.db.Transaction(func(tx *gorm.DB) error { + if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil { + return err + } + if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil { + return err + } + if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil { + return err + } + return nil + }) +} + // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { var count int64 diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 961d94e..82ae094 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -45,10 +45,15 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service { // SyncStatus represents the current sync status type SyncStatus struct { - LastSyncAt *time.Time `json:"last_sync_at"` - ServerPricelists int `json:"server_pricelists"` - LocalPricelists int `json:"local_pricelists"` - NeedsSync bool `json:"needs_sync"` + LastSyncAt *time.Time `json:"last_sync_at"` + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` + LastSyncStatus string `json:"last_sync_status,omitempty"` + LastSyncError string `json:"last_sync_error,omitempty"` + ServerPricelists int `json:"server_pricelists"` + LocalPricelists int `json:"local_pricelists"` + NeedsSync bool `json:"needs_sync"` + IncompleteServerSync bool `json:"incomplete_server_sync"` + KnownServerChangesMiss bool `json:"known_server_changes_missing"` } type UserSyncStatus struct { @@ -240,6 +245,9 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) { // GetStatus returns the current sync status func (s *Service) GetStatus() (*SyncStatus, error) { lastSync := s.localDB.GetLastSyncTime() + lastAttempt := s.localDB.GetLastPricelistSyncAttemptAt() + lastSyncStatus := s.localDB.GetLastPricelistSyncStatus() + lastSyncError := s.localDB.GetLastPricelistSyncError() // Count server pricelists (only if already connected, don't reconnect) serverCount := 0 @@ -260,10 +268,15 @@ func (s *Service) GetStatus() (*SyncStatus, error) { needsSync, _ := s.NeedSync() return &SyncStatus{ - LastSyncAt: lastSync, - ServerPricelists: serverCount, - LocalPricelists: int(localCount), - NeedsSync: needsSync, + LastSyncAt: lastSync, + LastAttemptAt: lastAttempt, + LastSyncStatus: lastSyncStatus, + LastSyncError: lastSyncError, + ServerPricelists: serverCount, + LocalPricelists: int(localCount), + NeedsSync: needsSync, + IncompleteServerSync: needsSync && strings.EqualFold(lastSyncStatus, "failed"), + KnownServerChangesMiss: needsSync, }, nil } @@ -333,6 +346,7 @@ func (s *Service) SyncPricelists() (int, error) { // Get database connection mariaDB, err := s.getDB() if err != nil { + s.recordPricelistSyncFailure(err) return 0, fmt.Errorf("database not available: %w", err) } @@ -342,6 +356,7 @@ func (s *Service) SyncPricelists() (int, error) { // Get active pricelists from server (up to 100) serverPricelists, _, err := pricelistRepo.ListActive(0, 100) if err != nil { + s.recordPricelistSyncFailure(err) return 0, fmt.Errorf("getting active server pricelists: %w", err) } serverPricelistIDs := make([]uint, 0, len(serverPricelists)) @@ -350,6 +365,7 @@ func (s *Service) SyncPricelists() (int, error) { } synced := 0 + var syncErr error for _, pl := range serverPricelists { // Check if pricelist already exists locally existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) @@ -358,6 +374,9 @@ func (s *Service) SyncPricelists() (int, error) { if s.localDB.CountLocalPricelistItems(existing.ID) == 0 { itemCount, err := s.SyncPricelistItems(existing.ID) if err != nil { + if syncErr == nil { + syncErr = fmt.Errorf("sync items for existing pricelist %s: %w", pl.Version, err) + } slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err) } else { slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount) @@ -377,19 +396,15 @@ func (s *Service) SyncPricelists() (int, error) { IsUsed: false, } - if err := s.localDB.SaveLocalPricelist(localPL); err != nil { - slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err) + itemCount, err := s.syncNewPricelistSnapshot(localPL) + if err != nil { + if syncErr == nil { + syncErr = fmt.Errorf("sync new pricelist %s: %w", pl.Version, err) + } + slog.Warn("failed to sync pricelist snapshot", "version", pl.Version, "error", err) continue } - - // Sync items for the newly created pricelist - itemCount, err := s.SyncPricelistItems(localPL.ID) - if err != nil { - slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err) - // Continue even if items sync fails - we have the pricelist metadata - } else { - slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) - } + slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount) synced++ } @@ -404,14 +419,78 @@ func (s *Service) SyncPricelists() (int, error) { // Backfill lot_category for used pricelists (older local caches may miss the column values). s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs) + if syncErr != nil { + s.recordPricelistSyncFailure(syncErr) + return synced, syncErr + } + // Update last sync time - s.localDB.SetLastSyncTime(time.Now()) + now := time.Now() + s.localDB.SetLastSyncTime(now) + s.recordPricelistSyncSuccess(now) s.RecordSyncHeartbeat() slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) return synced, nil } +func (s *Service) recordPricelistSyncSuccess(at time.Time) { + if s.localDB == nil { + return + } + if err := s.localDB.SetPricelistSyncResult("success", "", at); err != nil { + slog.Warn("failed to persist pricelist sync success state", "error", err) + } +} + +func (s *Service) recordPricelistSyncFailure(syncErr error) { + if s.localDB == nil || syncErr == nil { + return + } + if err := s.localDB.SetPricelistSyncResult("failed", syncErr.Error(), time.Now()); err != nil { + slog.Warn("failed to persist pricelist sync failure state", "error", err) + } +} + +func (s *Service) syncNewPricelistSnapshot(localPL *localdb.LocalPricelist) (int, error) { + if localPL == nil { + return 0, fmt.Errorf("local pricelist is nil") + } + + localItems, err := s.fetchServerPricelistItems(localPL.ServerID) + if err != nil { + return 0, err + } + + if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Create(localPL).Error; err != nil { + return fmt.Errorf("save local pricelist: %w", err) + } + if len(localItems) == 0 { + return nil + } + for i := range localItems { + localItems[i].PricelistID = localPL.ID + } + batchSize := 500 + for i := 0; i < len(localItems); i += batchSize { + end := i + batchSize + if end > len(localItems) { + end = len(localItems) + } + if err := tx.CreateInBatches(localItems[i:end], batchSize).Error; err != nil { + return fmt.Errorf("save local pricelist items: %w", err) + } + } + return nil + }); err != nil { + return 0, err + } + + slog.Info("synced pricelist items", "pricelist_id", localPL.ID, "items", len(localItems)) + return len(localItems), nil +} + func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) { if s.localDB == nil || pricelistRepo == nil { return @@ -670,30 +749,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { return int(existingCount), nil } - // Get database connection - mariaDB, err := s.getDB() + localItems, err := s.fetchServerPricelistItems(localPL.ServerID) if err != nil { - return 0, fmt.Errorf("database not available: %w", err) + return 0, err } - - // Create repository - pricelistRepo := repository.NewPricelistRepository(mariaDB) - - // Get items from server - serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") - if err != nil { - return 0, fmt.Errorf("getting server pricelist items: %w", err) + for i := range localItems { + localItems[i].PricelistID = localPricelistID } - - // Convert and save locally - localItems := make([]localdb.LocalPricelistItem, len(serverItems)) - for i, item := range serverItems { - localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID) - } - if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil { - slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err) - } - if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil { return 0, fmt.Errorf("saving local pricelist items: %w", err) } @@ -702,6 +764,33 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { return len(localItems), nil } +func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.LocalPricelistItem, error) { + // Get database connection + mariaDB, err := s.getDB() + if err != nil { + return nil, fmt.Errorf("database not available: %w", err) + } + + // Create repository + pricelistRepo := repository.NewPricelistRepository(mariaDB) + + // Get items from server + serverItems, _, err := pricelistRepo.GetItems(serverPricelistID, 0, 10000, "") + if err != nil { + return nil, fmt.Errorf("getting server pricelist items: %w", err) + } + + localItems := make([]localdb.LocalPricelistItem, len(serverItems)) + for i, item := range serverItems { + localItems[i] = *localdb.PricelistItemToLocal(&item, 0) + } + if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil { + slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err) + } + + return localItems, nil +} + // SyncPricelistItemsByServerID syncs items for a pricelist by its server ID func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) { localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID) diff --git a/internal/services/sync/service_pricelist_cleanup_test.go b/internal/services/sync/service_pricelist_cleanup_test.go index 33feb02..5e13bd7 100644 --- a/internal/services/sync/service_pricelist_cleanup_test.go +++ b/internal/services/sync/service_pricelist_cleanup_test.go @@ -1,12 +1,15 @@ package sync_test import ( + "errors" + "strings" "testing" "time" "git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/models" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" + "gorm.io/gorm" ) func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) { @@ -83,3 +86,58 @@ func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) { t.Fatalf("expected server pricelist to be synced locally: %v", err) } } + +func TestSyncPricelistsDoesNotPersistHeaderWithoutItems(t *testing.T) { + local := newLocalDBForSyncTest(t) + serverDB := newServerDBForSyncTest(t) + if err := serverDB.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil { + t.Fatalf("migrate server pricelist tables: %v", err) + } + + serverPL := models.Pricelist{ + Source: "estimate", + Version: "2026-03-17-001", + Notification: "server", + CreatedBy: "tester", + IsActive: true, + CreatedAt: time.Now().Add(-1 * time.Hour), + } + if err := serverDB.Create(&serverPL).Error; err != nil { + t.Fatalf("create server pricelist: %v", err) + } + if err := serverDB.Create(&models.PricelistItem{PricelistID: serverPL.ID, LotName: "CPU_A", Price: 10}).Error; err != nil { + t.Fatalf("create server pricelist item: %v", err) + } + + const callbackName = "test:fail_qt_pricelist_items_query" + if err := serverDB.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) { + if db.Statement != nil && db.Statement.Table == "qt_pricelist_items" { + _ = db.AddError(errors.New("forced pricelist item fetch failure")) + } + }); err != nil { + t.Fatalf("register query callback: %v", err) + } + defer serverDB.Callback().Query().Remove(callbackName) + + svc := syncsvc.NewServiceWithDB(serverDB, local) + synced, err := svc.SyncPricelists() + if err == nil { + t.Fatalf("expected sync error when item fetch fails") + } + if synced != 0 { + t.Fatalf("expected synced=0 on incomplete sync, got %d", synced) + } + if !strings.Contains(err.Error(), "forced pricelist item fetch failure") { + t.Fatalf("expected item fetch error, got %v", err) + } + + if _, err := local.GetLocalPricelistByServerID(serverPL.ID); err == nil { + t.Fatalf("expected pricelist header not to be persisted without items") + } + if got := local.CountLocalPricelists(); got != 0 { + t.Fatalf("expected no local pricelists after failed sync, got %d", got) + } + if ts := local.GetLastSyncTime(); ts != nil { + t.Fatalf("expected last_pricelist_sync to stay unset on incomplete sync, got %v", ts) + } +} diff --git a/web/templates/base.html b/web/templates/base.html index 9ba9e68..3120bfe 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -92,6 +92,15 @@ + +

Статистика

@@ -229,6 +238,43 @@ readinessMinVersion.textContent = ''; } + const syncIssueSection = document.getElementById('modal-pricelist-sync-issue'); + const syncIssueSummary = document.getElementById('modal-pricelist-sync-summary'); + const syncIssueAttempt = document.getElementById('modal-pricelist-sync-attempt'); + const syncIssueError = document.getElementById('modal-pricelist-sync-error'); + const hasSyncFailure = data.last_pricelist_sync_status === 'failed'; + if (data.has_incomplete_server_sync) { + syncIssueSection.classList.remove('hidden'); + syncIssueSummary.textContent = 'Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые еще не загружены локально.'; + } else if (hasSyncFailure) { + syncIssueSection.classList.remove('hidden'); + syncIssueSummary.textContent = 'Последняя синхронизация прайслистов завершилась ошибкой.'; + } else { + syncIssueSection.classList.add('hidden'); + syncIssueSummary.textContent = ''; + } + if (syncIssueSection.classList.contains('hidden')) { + syncIssueAttempt.classList.add('hidden'); + syncIssueAttempt.textContent = ''; + syncIssueError.classList.add('hidden'); + syncIssueError.textContent = ''; + } else { + if (data.last_pricelist_attempt_at) { + syncIssueAttempt.classList.remove('hidden'); + syncIssueAttempt.textContent = 'Последняя попытка: ' + new Date(data.last_pricelist_attempt_at).toLocaleString('ru-RU'); + } else { + syncIssueAttempt.classList.add('hidden'); + syncIssueAttempt.textContent = ''; + } + if (data.last_pricelist_sync_error) { + syncIssueError.classList.remove('hidden'); + syncIssueError.textContent = data.last_pricelist_sync_error; + } else { + syncIssueError.classList.add('hidden'); + syncIssueError.textContent = ''; + } + } + // Section 2: Statistics document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—'; document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—'; diff --git a/web/templates/index.html b/web/templates/index.html index b993e34..d71cee4 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2558,7 +2558,7 @@ async function refreshPrices() { } // Re-render UI - await refreshPriceLevels(); + await refreshPriceLevels({ force: true, noCache: true }); renderTab(); updateCartUI(); diff --git a/web/templates/partials/sync_status.html b/web/templates/partials/sync_status.html index 3918fda..d824c8d 100644 --- a/web/templates/partials/sync_status.html +++ b/web/templates/partials/sync_status.html @@ -14,7 +14,21 @@ {{end}} - {{if .IsBlocked}} + {{if .HasIncompleteServerSync}} + + + + + Не докачано + + {{else if .HasFailedSync}} + + + + + Sync error + + {{else if .IsBlocked}}