diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go index d763055..51e94fb 100644 --- a/server/app/goauto/models/yeeke.go +++ b/server/app/goauto/models/yeeke.go @@ -74,24 +74,27 @@ type YeekeReturnItem struct { func (YeekeReturnItem) TableName() string { return "yeeke_return_item" } type YeekeSyncRun struct { - ID uint64 `gorm:"primaryKey;autoIncrement"` - Status string `gorm:"size:32;not null;index"` - Trigger string `gorm:"size:32;not null;index"` - TotalPages int `gorm:"not null;default:0"` - ReadCount int `gorm:"not null;default:0"` - CreatedCount int `gorm:"not null;default:0"` - UpdatedCount int `gorm:"not null;default:0"` - SkippedCount int `gorm:"not null;default:0"` - FailedCount int `gorm:"not null;default:0"` - ErrorMessage string `gorm:"size:1000;not null;default:''"` - StartedAt time.Time `gorm:"not null"` - FinishedAt *time.Time - LastSuccessAt *time.Time - ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` - LeaseOwner string `gorm:"size:128;not null;default:''"` - LeaseExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 `gorm:"primaryKey;autoIncrement"` + Status string `gorm:"size:32;not null;index"` + Trigger string `gorm:"size:32;not null;index"` + TotalPages int `gorm:"not null;default:0"` + ReadCount int `gorm:"not null;default:0"` + CreatedCount int `gorm:"not null;default:0"` + UpdatedCount int `gorm:"not null;default:0"` + SkippedCount int `gorm:"not null;default:0"` + FailedCount int `gorm:"not null;default:0"` + // #338: items flipped to "missing" / back to "ok" by this run. + MissingMarkedCount int `gorm:"not null;default:0"` + RecoveredCount int `gorm:"not null;default:0"` + ErrorMessage string `gorm:"size:1000;not null;default:''"` + StartedAt time.Time `gorm:"not null"` + FinishedAt *time.Time + LastSuccessAt *time.Time + ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` + LeaseOwner string `gorm:"size:128;not null;default:''"` + LeaseExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" } diff --git a/server/app/goauto/yeeke/handler.go b/server/app/goauto/yeeke/handler.go index 1417aa0..e419b5a 100644 --- a/server/app/goauto/yeeke/handler.go +++ b/server/app/goauto/yeeke/handler.go @@ -43,26 +43,29 @@ func (h Handler) db(c *gin.Context) (*gorm.DB, bool) { // only the summary fields already computed by the sync run itself; it never // carries yeeke_session (token/cookies) or a raw page response. type SyncRunDTO struct { - ID uint64 `json:"id"` - Status string `json:"status"` - Trigger string `json:"trigger"` - TotalPages int `json:"totalPages"` - ReadCount int `json:"readCount"` - CreatedCount int `json:"createdCount"` - UpdatedCount int `json:"updatedCount"` - SkippedCount int `json:"skippedCount"` - FailedCount int `json:"failedCount"` - ErrorMessage string `json:"errorMessage"` - StartedAt string `json:"startedAt"` - FinishedAt *string `json:"finishedAt"` - LastSuccessAt *string `json:"lastSuccessAt"` + ID uint64 `json:"id"` + Status string `json:"status"` + Trigger string `json:"trigger"` + TotalPages int `json:"totalPages"` + ReadCount int `json:"readCount"` + CreatedCount int `json:"createdCount"` + UpdatedCount int `json:"updatedCount"` + SkippedCount int `json:"skippedCount"` + FailedCount int `json:"failedCount"` + MissingMarkedCount int `json:"missingMarkedCount"` + RecoveredCount int `json:"recoveredCount"` + ErrorMessage string `json:"errorMessage"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` + LastSuccessAt *string `json:"lastSuccessAt"` } func toDTO(r models.YeekeSyncRun) SyncRunDTO { dto := SyncRunDTO{ ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages, ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount, - SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage, + SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, + MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage, StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), } if r.FinishedAt != nil { diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index 38cec1a..ef68630 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -43,10 +43,14 @@ type Report struct { // MissingMarked is the number of yeeke_return_item rows flipped from // "ok" to "missing" by this run's completion (#338). It is only ever // non-zero on a run whose page walk finished naturally, wrote no - // failures, and stayed under the 20% safety-valve threshold; it is not - // persisted as a run column, only reported here and folded into - // error_message when the safety valve skips marking. + // failures, and stayed under the 20% safety-valve threshold. Persisted + // as yeeke_sync_run.missing_marked_count; when the safety valve skips + // marking it stays 0 and the reason goes to error_message. MissingMarked int + // Recovered is the number of yeeke_return_item rows that were "missing" + // before this run and reappeared in it, flipping back to "ok" (#338). + // Persisted as yeeke_sync_run.recovered_count. + Recovered int } // knownClaimStatuses lists the status values the sync code currently @@ -184,7 +188,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro var runErr error defer func() { now := time.Now().UTC() - updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} + updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} if rep.Status == "succeeded" { updates["last_success_at"] = now } @@ -235,7 +239,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } seen[finger] = true for _, x := range p.Records { - created, updated, err := s.upsert(ctx, x) + created, updated, recovered, err := s.upsert(ctx, x) if err != nil { rep.Failed++ if firstWriteErr == nil { @@ -244,6 +248,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro continue } rep.Read++ + rep.Recovered += recovered if created { rep.Created++ } else if updated { @@ -354,7 +359,10 @@ func pageFingerprint(p yeekeclient.ReturnPage) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } -func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) { + +// upsert writes one package and its items. recovered counts items that were +// marked "missing" before and reappeared in this run (#338). +func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) { now := time.Now().UTC() key := packageKey(p) status := external(p.Status) @@ -363,7 +371,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool // behind without its items. Rows are always inserted fully populated — // MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an // empty placeholder insert would carry, which failed every record. - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row models.YeekeReturnPackage e := tx.Where("external_id = ?", key).First(&row).Error isNew = errors.Is(e, gorm.ErrRecordNotFound) @@ -411,6 +419,9 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool case ie != nil: return ie default: + if existing.SyncStatus == "missing" { + recovered++ + } iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&existing).Updates(iv).Error; e != nil { return e @@ -420,7 +431,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool return nil }) if err != nil { - return false, false, err + return false, false, 0, err } - return isNew, !isNew, nil + return isNew, !isNew, recovered, nil } diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go index 2e95322..0e80fc8 100644 --- a/server/app/goauto/yeeke/sync_missing_test.go +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -366,9 +366,24 @@ func TestReappearingRecordRecoversFromMissing(t *testing.T) { })) defer srv3.Close() s.client, _ = yeekeclient.New(srv3.URL) - if _, err := s.Sync(context.Background(), "manual"); err != nil { + rep3, err := s.Sync(context.Background(), "manual") + if err != nil { t.Fatalf("run 3 (recovery): %v", err) } + if rep3.Recovered != 1 || rep3.MissingMarked != 0 { + t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked) + } + // Both counters are persisted on the run rows shown by the sync-runs page. + var run2, run3 models.YeekeSyncRun + if e := db.First(&run2, rep2.RunID).Error; e != nil { + t.Fatal(e) + } + if e := db.First(&run3, rep3.RunID).Error; e != nil { + t.Fatal(e) + } + if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 { + t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount) + } // A fresh variable is used here (not the p1 declared above): GORM's Scan // does not reset an already non-nil pointer field to nil when the new diff --git a/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go new file mode 100644 index 0000000..c0e8a43 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts) +} +func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/views/goauto/yeeke-sync-runs/index.vue b/web/src/views/goauto/yeeke-sync-runs/index.vue index 3b96eb3..1084c04 100644 --- a/web/src/views/goauto/yeeke-sync-runs/index.vue +++ b/web/src/views/goauto/yeeke-sync-runs/index.vue @@ -41,6 +41,8 @@ + +