Fix incomplete pricelist sync status
This commit is contained in:
@@ -35,6 +35,8 @@ Readiness guard:
|
|||||||
- blocked sync returns `423 Locked` with a machine-readable reason;
|
- blocked sync returns `423 Locked` with a machine-readable reason;
|
||||||
- local work continues even when sync is blocked.
|
- 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.
|
- 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
|
## Pricing contract
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
stdsync "sync"
|
stdsync "sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -49,15 +50,20 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
type SyncStatusResponse struct {
|
type SyncStatusResponse struct {
|
||||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
IsOnline bool `json:"is_online"`
|
LastPricelistAttemptAt *time.Time `json:"last_pricelist_attempt_at,omitempty"`
|
||||||
ComponentsCount int64 `json:"components_count"`
|
LastPricelistSyncStatus string `json:"last_pricelist_sync_status,omitempty"`
|
||||||
PricelistsCount int64 `json:"pricelists_count"`
|
LastPricelistSyncError string `json:"last_pricelist_sync_error,omitempty"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
HasIncompleteServerSync bool `json:"has_incomplete_server_sync"`
|
||||||
NeedComponentSync bool `json:"need_component_sync"`
|
KnownServerChangesMiss bool `json:"known_server_changes_missing"`
|
||||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
IsOnline bool `json:"is_online"`
|
||||||
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
|
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 {
|
type SyncReadinessResponse struct {
|
||||||
@@ -86,11 +92,21 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
|||||||
// Get server pricelist count if online
|
// Get server pricelist count if online
|
||||||
serverPricelists := 0
|
serverPricelists := 0
|
||||||
needPricelistSync := false
|
needPricelistSync := false
|
||||||
|
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
|
hasIncompleteServerSync := false
|
||||||
|
knownServerChangesMissing := false
|
||||||
if isOnline {
|
if isOnline {
|
||||||
status, err := h.syncService.GetStatus()
|
status, err := h.syncService.GetStatus()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
serverPricelists = status.ServerPricelists
|
serverPricelists = status.ServerPricelists
|
||||||
needPricelistSync = status.NeedsSync
|
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)
|
readiness := h.getReadinessCached(10 * time.Second)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
LastComponentSync: lastComponentSync,
|
LastComponentSync: lastComponentSync,
|
||||||
LastPricelistSync: lastPricelistSync,
|
LastPricelistSync: lastPricelistSync,
|
||||||
IsOnline: isOnline,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
ComponentsCount: componentsCount,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
PricelistsCount: pricelistsCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
ServerPricelists: serverPricelists,
|
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||||
NeedComponentSync: needComponentSync,
|
KnownServerChangesMiss: knownServerChangesMissing,
|
||||||
NeedPricelistSync: needPricelistSync,
|
IsOnline: isOnline,
|
||||||
Readiness: readiness,
|
ComponentsCount: componentsCount,
|
||||||
|
PricelistsCount: pricelistsCount,
|
||||||
|
ServerPricelists: serverPricelists,
|
||||||
|
NeedComponentSync: needComponentSync,
|
||||||
|
NeedPricelistSync: needPricelistSync,
|
||||||
|
Readiness: readiness,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,8 +495,13 @@ type SyncInfoResponse struct {
|
|||||||
DBName string `json:"db_name"`
|
DBName string `json:"db_name"`
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
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
|
// Statistics
|
||||||
LotCount int64 `json:"lot_count"`
|
LotCount int64 `json:"lot_count"`
|
||||||
@@ -524,6 +550,11 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
|
|
||||||
// Get sync times
|
// Get sync times
|
||||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
lastPricelistAttemptAt := h.localDB.GetLastPricelistSyncAttemptAt()
|
||||||
|
lastPricelistSyncStatus := h.localDB.GetLastPricelistSyncStatus()
|
||||||
|
lastPricelistSyncError := h.localDB.GetLastPricelistSyncError()
|
||||||
|
needPricelistSync := false
|
||||||
|
hasIncompleteServerSync := false
|
||||||
|
|
||||||
// Get local counts
|
// Get local counts
|
||||||
configCount := h.localDB.CountConfigurations()
|
configCount := h.localDB.CountConfigurations()
|
||||||
@@ -557,21 +588,35 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
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{
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
DBUser: dbUser,
|
DBUser: dbUser,
|
||||||
DBName: dbName,
|
DBName: dbName,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
LastSyncAt: lastPricelistSync,
|
LastSyncAt: lastPricelistSync,
|
||||||
LotCount: componentCount,
|
LastPricelistAttemptAt: lastPricelistAttemptAt,
|
||||||
LotLogCount: pricelistCount,
|
LastPricelistSyncStatus: lastPricelistSyncStatus,
|
||||||
ConfigCount: configCount,
|
LastPricelistSyncError: lastPricelistSyncError,
|
||||||
ProjectCount: projectCount,
|
NeedPricelistSync: needPricelistSync,
|
||||||
PendingChanges: changes,
|
HasIncompleteServerSync: hasIncompleteServerSync,
|
||||||
ErrorCount: errorCount,
|
LotCount: componentCount,
|
||||||
Errors: syncErrors,
|
LotLogCount: pricelistCount,
|
||||||
Readiness: readiness,
|
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()
|
pendingCount := h.localDB.GetPendingCount()
|
||||||
readiness := h.getReadinessCached(10 * time.Second)
|
readiness := h.getReadinessCached(10 * time.Second)
|
||||||
isBlocked := readiness != nil && readiness.Blocked
|
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)
|
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"IsOffline": isOffline,
|
"IsOffline": isOffline,
|
||||||
"PendingCount": pendingCount,
|
"PendingCount": pendingCount,
|
||||||
"IsBlocked": isBlocked,
|
"IsBlocked": isBlocked,
|
||||||
|
"HasFailedSync": hasFailedSync,
|
||||||
|
"HasIncompleteServerSync": hasIncompleteServerSync,
|
||||||
|
"SyncIssueTitle": func() string {
|
||||||
|
if hasIncompleteServerSync {
|
||||||
|
return "Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые не загружены локально."
|
||||||
|
}
|
||||||
|
if hasFailedSync {
|
||||||
|
if lastPricelistSyncError != "" {
|
||||||
|
return lastPricelistSyncError
|
||||||
|
}
|
||||||
|
return "Последняя синхронизация прайслистов завершилась ошибкой."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
"BlockedReason": func() string {
|
"BlockedReason": func() string {
|
||||||
if readiness == nil {
|
if readiness == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1066,6 +1066,26 @@ func (l *LocalDB) GetLastSyncTime() *time.Time {
|
|||||||
return &t
|
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
|
// SetLastSyncTime sets the last sync timestamp
|
||||||
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
|
||||||
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
|
// 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
|
`, "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
|
// CountLocalPricelists returns the number of local pricelists
|
||||||
func (l *LocalDB) CountLocalPricelists() int64 {
|
func (l *LocalDB) CountLocalPricelists() int64 {
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ func NewServiceWithDB(mariaDB *gorm.DB, localDB *localdb.LocalDB) *Service {
|
|||||||
|
|
||||||
// SyncStatus represents the current sync status
|
// SyncStatus represents the current sync status
|
||||||
type SyncStatus struct {
|
type SyncStatus struct {
|
||||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||||
LocalPricelists int `json:"local_pricelists"`
|
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||||
NeedsSync bool `json:"needs_sync"`
|
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 {
|
type UserSyncStatus struct {
|
||||||
@@ -240,6 +245,9 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
// GetStatus returns the current sync status
|
// GetStatus returns the current sync status
|
||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
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)
|
// Count server pricelists (only if already connected, don't reconnect)
|
||||||
serverCount := 0
|
serverCount := 0
|
||||||
@@ -260,10 +268,15 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
|||||||
needsSync, _ := s.NeedSync()
|
needsSync, _ := s.NeedSync()
|
||||||
|
|
||||||
return &SyncStatus{
|
return &SyncStatus{
|
||||||
LastSyncAt: lastSync,
|
LastSyncAt: lastSync,
|
||||||
ServerPricelists: serverCount,
|
LastAttemptAt: lastAttempt,
|
||||||
LocalPricelists: int(localCount),
|
LastSyncStatus: lastSyncStatus,
|
||||||
NeedsSync: needsSync,
|
LastSyncError: lastSyncError,
|
||||||
|
ServerPricelists: serverCount,
|
||||||
|
LocalPricelists: int(localCount),
|
||||||
|
NeedsSync: needsSync,
|
||||||
|
IncompleteServerSync: needsSync && strings.EqualFold(lastSyncStatus, "failed"),
|
||||||
|
KnownServerChangesMiss: needsSync,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +346,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
// Get database connection
|
// Get database connection
|
||||||
mariaDB, err := s.getDB()
|
mariaDB, err := s.getDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordPricelistSyncFailure(err)
|
||||||
return 0, fmt.Errorf("database not available: %w", 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)
|
// Get active pricelists from server (up to 100)
|
||||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordPricelistSyncFailure(err)
|
||||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
serverPricelistIDs := make([]uint, 0, len(serverPricelists))
|
||||||
@@ -350,6 +365,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
|
var syncErr error
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
@@ -358,6 +374,9 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
if s.localDB.CountLocalPricelistItems(existing.ID) == 0 {
|
||||||
itemCount, err := s.SyncPricelistItems(existing.ID)
|
itemCount, err := s.SyncPricelistItems(existing.ID)
|
||||||
if err != nil {
|
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)
|
slog.Warn("failed to sync missing pricelist items for existing local pricelist", "version", pl.Version, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("synced missing pricelist items for existing local pricelist", "version", pl.Version, "items", itemCount)
|
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,
|
IsUsed: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
|
itemCount, err := s.syncNewPricelistSnapshot(localPL)
|
||||||
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
synced++
|
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).
|
// Backfill lot_category for used pricelists (older local caches may miss the column values).
|
||||||
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
|
||||||
|
|
||||||
|
if syncErr != nil {
|
||||||
|
s.recordPricelistSyncFailure(syncErr)
|
||||||
|
return synced, syncErr
|
||||||
|
}
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
now := time.Now()
|
||||||
|
s.localDB.SetLastSyncTime(now)
|
||||||
|
s.recordPricelistSyncSuccess(now)
|
||||||
s.RecordSyncHeartbeat()
|
s.RecordSyncHeartbeat()
|
||||||
|
|
||||||
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
|
||||||
return synced, nil
|
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) {
|
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
|
||||||
if s.localDB == nil || pricelistRepo == nil {
|
if s.localDB == nil || pricelistRepo == nil {
|
||||||
return
|
return
|
||||||
@@ -670,30 +749,13 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return int(existingCount), nil
|
return int(existingCount), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get database connection
|
localItems, err := s.fetchServerPricelistItems(localPL.ServerID)
|
||||||
mariaDB, err := s.getDB()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("database not available: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
|
for i := range localItems {
|
||||||
// Create repository
|
localItems[i].PricelistID = localPricelistID
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
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
|
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
|
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
|
||||||
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
|
||||||
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package sync_test
|
package sync_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSyncPricelistsDeletesMissingUnusedLocalPricelists(t *testing.T) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-pricelist-sync-issue" class="hidden">
|
||||||
|
<h4 class="font-medium text-red-700 mb-2">Состояние прайслистов</h4>
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm space-y-1">
|
||||||
|
<div id="modal-pricelist-sync-summary" class="text-red-700">—</div>
|
||||||
|
<div id="modal-pricelist-sync-attempt" class="text-red-600 text-xs hidden"></div>
|
||||||
|
<div id="modal-pricelist-sync-error" class="text-red-600 text-xs hidden whitespace-pre-wrap"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Section 2: Statistics -->
|
<!-- Section 2: Statistics -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||||||
@@ -229,6 +238,43 @@
|
|||||||
readinessMinVersion.textContent = '';
|
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
|
// Section 2: Statistics
|
||||||
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
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() : '—';
|
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
||||||
|
|||||||
@@ -2558,7 +2558,7 @@ async function refreshPrices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-render UI
|
// Re-render UI
|
||||||
await refreshPriceLevels();
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
renderTab();
|
renderTab();
|
||||||
updateCartUI();
|
updateCartUI();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,21 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsBlocked}}
|
{{if .HasIncompleteServerSync}}
|
||||||
|
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
Не докачано
|
||||||
|
</span>
|
||||||
|
{{else if .HasFailedSync}}
|
||||||
|
<span class="bg-orange-100 text-orange-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.SyncIssueTitle}}" onclick="openSyncModal()">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M4.93 19h14.14c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.2 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
Sync error
|
||||||
|
</span>
|
||||||
|
{{else if .IsBlocked}}
|
||||||
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
|
<span class="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" title="{{.BlockedReason}}" onclick="openSyncModal()">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-7.938 4h15.876c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L2.33 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
|||||||
Reference in New Issue
Block a user