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 @@
+
+
{{ row.errorMessage || '—' }}