Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72fa105a8 | ||
|
|
5ac8e4c8fd |
@@ -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" }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<el-alert v-if="isDeadlinePassed(returnMatchByProductId[row.id].destroyDeadline)" title="退货已过销毁截止" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
@@ -223,6 +224,7 @@
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">yeeke 退货商品</h3>
|
||||
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="matchDetail.data.yeeke && isDeadlinePassed(matchDetail.data.yeeke.destroyDeadline)" title="退货已过销毁截止,请核实退货是否仍在库" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||
@@ -540,6 +542,8 @@ export default {
|
||||
|
||||
// ---------------- 退货匹配 (#338) ----------------
|
||||
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||
// #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
|
||||
async loadReturnMatches(ids, generation = this.loadGeneration) {
|
||||
this.returnMatchByProductId = {}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<template v-if="row.occupyingSybProductId">
|
||||
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
|
||||
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
|
||||
<div v-if="isDeadlinePassed(row.destroyDeadLine)" class="deadline-passed">已过销毁截止</div>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
@@ -193,6 +194,8 @@ export default {
|
||||
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
|
||||
// #338: a matched return past its destroy deadline is flagged only, never auto-released.
|
||||
isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() },
|
||||
async load() {
|
||||
this.loading = true; this.loadError = ''
|
||||
try {
|
||||
@@ -277,4 +280,5 @@ export default {
|
||||
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
|
||||
.muted{font-size:12px;color:#909399}
|
||||
@media(max-width:800px){.page-heading{flex-direction:column}}
|
||||
.deadline-passed{color:var(--el-color-danger);font-size:12px}
|
||||
</style>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
<el-table-column label="更新" prop="updatedCount" width="70" />
|
||||
<el-table-column label="跳过" prop="skippedCount" width="70" />
|
||||
<el-table-column label="失败" prop="failedCount" width="70" />
|
||||
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
|
||||
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
|
||||
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user