From ff6e87649cabbec1ea05568a0ef1ff5a5aace380 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 15:57:38 +0800 Subject: [PATCH] feat(returnmatch): mark yeeke returns unavailable when a complete sync drops them (#338) A COMPLETE yeeke sync run (natural page-walk end, not stopped by a duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero record failures now flips any yeeke_return_package/yeeke_return_item still "ok" from before the run to sync_status="missing" with missing_since stamped, in one transaction. Rows are never deleted. A 20% safety valve skips marking (and records why in error_message) when the candidate count would be too large; a reappearing record recovers to "ok" with missing_since cleared. returnmatch.availableReturnPool now excludes missing items/packages from matching, without auto-cancelling any existing active match; List()/Detail() surface syncStatus/missingSince so the SYB products match column, its compare dialog, and the yeeke returns list can warn or label rows as unavailable. Adds migration 1789800900000_return_missing (AutoMigrate alone does not reach existing databases). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/models/yeeke.go | 22 +- server/app/goauto/returnmatch/missing_test.go | 158 +++++++ server/app/goauto/returnmatch/service.go | 61 ++- server/app/goauto/yeeke/list.go | 46 ++- server/app/goauto/yeeke/sync.go | 88 +++- server/app/goauto/yeeke/sync_missing_test.go | 391 ++++++++++++++++++ .../1789800900000_return_missing.go | 28 ++ web/src/views/goauto/syb-products/index.vue | 3 + web/src/views/goauto/yeeke-returns/index.vue | 6 + 9 files changed, 773 insertions(+), 30 deletions(-) create mode 100644 server/app/goauto/returnmatch/missing_test.go create mode 100644 server/app/goauto/yeeke/sync_missing_test.go create mode 100644 server/cmd/migrate/migration/version-local/1789800900000_return_missing.go diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go index a158287..d763055 100644 --- a/server/app/goauto/models/yeeke.go +++ b/server/app/goauto/models/yeeke.go @@ -38,9 +38,14 @@ type YeekeReturnPackage struct { UpdateTime *time.Time DestroyDeadLine *time.Time LastSyncedAt time.Time `gorm:"not null;index"` - SyncStatus string `gorm:"size:32;not null;default:'ok'"` - CreatedAt time.Time - UpdatedAt time.Time + // SyncStatus is "ok" while the package still appears in a COMPLETE yeeke + // sync; #338 sets it to "missing" (with MissingSince stamped) once a + // completed sync no longer sees it, so return matching stops using it. + // It is never deleted or marked "已销毁" — only flagged unavailable. + SyncStatus string `gorm:"size:32;not null;default:'ok';index"` + MissingSince *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" } @@ -56,9 +61,14 @@ type YeekeReturnItem struct { Image string `gorm:"type:text;not null"` Quantity int64 `gorm:"not null;default:0"` LastSyncedAt time.Time `gorm:"not null;index"` - SyncStatus string `gorm:"size:32;not null;default:'ok'"` - CreatedAt time.Time - UpdatedAt time.Time + // SyncStatus/MissingSince mirror YeekeReturnPackage's fields (#338): once + // a COMPLETE yeeke sync no longer sees this item it is flagged "missing" + // so returnmatch.availableReturnPool stops offering it, without ever + // deleting the row. + SyncStatus string `gorm:"size:32;not null;default:'ok';index"` + MissingSince *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeReturnItem) TableName() string { return "yeeke_return_item" } diff --git a/server/app/goauto/returnmatch/missing_test.go b/server/app/goauto/returnmatch/missing_test.go new file mode 100644 index 0000000..fcb3fd8 --- /dev/null +++ b/server/app/goauto/returnmatch/missing_test.go @@ -0,0 +1,158 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is +// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered +// to matching, even though it otherwise satisfies every other rule (an +// eligible SYB candidate, a future destroy deadline, no active match). +func TestBatchMatch_MissingItemNotMatched(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + now := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp) + } + if resp.Items[0].ReasonCode != ReasonNoCandidate { + t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0]) + } +} + +// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself +// is still "ok" while its PACKAGE is "missing" — availableReturnPool must +// exclude it too, since #338's rule is "an item, or an item whose package, +// is no longer ok". +func TestBatchMatch_MissingPackageNotMatched(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + now := time.Now().UTC() + if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp) + } +} + +// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing +// active match is NOT auto-cancelled when its return item later becomes +// missing. Detail() must still report it (SYB side unaffected) and surface +// the yeeke side's syncStatus="missing" so the UI can show the warning. +func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 { + t.Fatalf("expected a match before the item goes missing: %+v", resp) + } + matchID := resp.Items[0].MatchID + + // The return item now drops out of a COMPLETE yeeke sync. + missingSince := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil { + t.Fatal(err) + } + + var match models.ReturnMatch + if err := db.First(&match, matchID).Error; err != nil { + t.Fatal(err) + } + if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil { + t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match) + } + + detail, err := s.Detail(context.Background(), matchID) + if err != nil { + t.Fatal(err) + } + if detail.Yeeke == nil { + t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)") + } + if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil { + t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke) + } + if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID { + t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB) + } +} + +// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and +// the compare dialog read List()'s syncStatus/missingSince fields (#338); a +// match whose return item is missing must carry them through. +func TestList_ReportsMissingReturnSyncStatus(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 { + t.Fatalf("expected a match: %+v", resp) + } + + missingSince := time.Now().UTC() + if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID). + Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil { + t.Fatal(err) + } + + items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("expected one list row, got %d", len(items)) + } + if items[0].SyncStatus != "missing" || items[0].MissingSince == nil { + t.Fatalf("List() must surface the missing return item's status, got %+v", items[0]) + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index d12a1be..f20adec 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -264,6 +264,9 @@ func sortSYBCandidatesDesc(items []SYBCandidate) { // availableReturnPool loads every yeeke return item with no active match and // a destroy deadline (rule 2: empty deadline is unavailable, handled by the // NULL exclusion below; "later than now" is enforced by SelectMatches). +// #338 scope addition: an item, or an item whose package, is no longer +// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is +// excluded here so matching never offers a return yeeke has dropped. func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) { var rows []struct { ID uint64 @@ -275,7 +278,7 @@ func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, e Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id"). - Where("m.id IS NULL"). + Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok"). Find(&rows).Error if err != nil { return nil, err @@ -449,6 +452,12 @@ type ListItem struct { Image string `json:"image,omitempty"` VariationName string `json:"variationName,omitempty"` DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` + // SyncStatus/MissingSince (#338) surface the yeeke return item's own + // current availability ("ok"/"missing") so the SYB products match column + // and the compare dialog can warn even on an existing, already-matched + // pair whose return later dropped out of a COMPLETE yeeke sync. + SyncStatus string `json:"syncStatus,omitempty"` + MissingSince *time.Time `json:"missingSince,omitempty"` } func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) { @@ -478,32 +487,42 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro // One bounded join query for every return item referenced on this page — // never a per-row lookup. var joined []struct { - ID uint64 - OrderSN string - Image string - VariationName string - DestroyDeadLine *time.Time + ID uint64 + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + SyncStatus string + MissingSince *time.Time + PackageSyncStat string `gorm:"column:package_sync_status"` + PackageMissingAt *time.Time `gorm:"column:package_missing_since"` } if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i"). - Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line"). + Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+ + "i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since"). Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id"). Where("i.id IN ?", returnIDs). Find(&joined).Error; err != nil { return nil, err } - byID := make(map[uint64]struct { + type extraFields struct { OrderSN string Image string VariationName string DestroyDeadLine *time.Time - }, len(joined)) + SyncStatus string + MissingSince *time.Time + } + byID := make(map[uint64]extraFields, len(joined)) for _, j := range joined { - byID[j.ID] = struct { - OrderSN string - Image string - VariationName string - DestroyDeadLine *time.Time - }{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine} + // A missing package makes its items unavailable too (#338), even + // if the item row itself is still "ok" — surface the package's + // missing_since in that case since it is the more accurate reason. + syncStatus, missingSince := j.SyncStatus, j.MissingSince + if j.PackageSyncStat != "ok" { + syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt + } + byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince} } for i := range items { if extra, ok := byID[items[i].YeekeReturnItemID]; ok { @@ -511,6 +530,8 @@ func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, erro items[i].Image = extra.Image items[i].VariationName = extra.VariationName items[i].DestroyDeadline = extra.DestroyDeadLine + items[i].SyncStatus = extra.SyncStatus + items[i].MissingSince = extra.MissingSince } } return items, nil @@ -544,6 +565,11 @@ type YeekeDetailView struct { Quantity int64 `json:"quantity"` Image string `json:"image,omitempty"` DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"` + // SyncStatus/MissingSince (#338): "missing" when either the item or its + // package fell out of a COMPLETE yeeke sync, so the compare dialog can + // show the warning even on a match made before the item went missing. + SyncStatus string `json:"syncStatus"` + MissingSince *time.Time `json:"missingSince,omitempty"` } type MatchDetail struct { @@ -586,10 +612,15 @@ func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, erro if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) { return detail, pkgErr } + syncStatus, missingSince := item.SyncStatus, item.MissingSince + if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" { + syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince + } detail.Yeeke = &YeekeDetailView{ YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID, ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName, Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine, + SyncStatus: syncStatus, MissingSince: missingSince, } detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName)) } else if !errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/server/app/goauto/yeeke/list.go b/server/app/goauto/yeeke/list.go index 5070036..1499cce 100644 --- a/server/app/goauto/yeeke/list.go +++ b/server/app/goauto/yeeke/list.go @@ -20,13 +20,15 @@ import ( // image field is always the external yeeke URL (never downloaded/proxied, // per #337 non-goal). type ReturnItemDTO struct { - ID uint64 `json:"id"` - ItemID string `json:"itemId"` - VariationID string `json:"variationId"` - ItemName string `json:"itemName"` - VariationName string `json:"variationName"` - Image string `json:"image"` - Quantity int64 `json:"quantity"` + ID uint64 `json:"id"` + ItemID string `json:"itemId"` + VariationID string `json:"variationId"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + Image string `json:"image"` + Quantity int64 `json:"quantity"` + SyncStatus string `json:"syncStatus"` + MissingSince *string `json:"missingSince,omitempty"` } // ReturnPackageDTO is the read-only shape of one return package returned to @@ -50,6 +52,7 @@ type ReturnPackageDTO struct { DestroyDeadLine *string `json:"destroyDeadLine"` LastSyncedAt string `json:"lastSyncedAt"` SyncStatus string `json:"syncStatus"` + MissingSince *string `json:"missingSince,omitempty"` Items []ReturnItemDTO `json:"items,omitempty"` } @@ -78,6 +81,12 @@ type ReturnItemRowDTO struct { LastSyncedAt string `json:"lastSyncedAt"` HasItem bool `json:"hasItem"` + // SyncStatus/MissingSince (#338 scope addition): "missing" when this + // item, or its package, has fallen out of a COMPLETE yeeke sync — the + // row is never dropped from this list, only flagged unavailable. + SyncStatus string `json:"syncStatus,omitempty"` + MissingSince *string `json:"missingSince,omitempty"` + // #338: match status/占用信息, joined from return_match (active match // only). MatchStatus is "unmatched" when there is no active match. MatchID uint64 `json:"matchId,omitempty"` @@ -110,6 +119,7 @@ func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO { ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime), UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine), LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus, + MissingSince: formatTimePtr(p.MissingSince), } } @@ -117,6 +127,7 @@ func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO { return ReturnItemDTO{ ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity, + SyncStatus: i.SyncStatus, MissingSince: formatTimePtr(i.MissingSince), } } @@ -143,6 +154,14 @@ type itemRow struct { ItemQuantity *int64 ItemLastSynced *time.Time + // #338 scope addition: item's own and its package's sync_status/ + // missing_since, so the row can be flagged unavailable regardless of + // which side dropped out of a COMPLETE yeeke sync. + ItemSyncStatus *string `gorm:"column:item_sync_status"` + ItemMissingSince *time.Time `gorm:"column:item_missing_since"` + PackageSyncStatus string `gorm:"column:package_sync_status"` + PackageMissingSince *time.Time `gorm:"column:package_missing_since"` + // #338 match columns, from the LEFT JOIN onto return_match/syb_product. MatchID *uint64 `gorm:"column:match_id"` MatchStatus *string `gorm:"column:match_status"` @@ -188,6 +207,17 @@ func toItemRowDTO(r itemRow) ReturnItemRowDTO { if r.ItemLastSynced != nil { dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z") } + // #338: the package being "missing" makes the item unavailable too, + // even if the item row itself is still "ok" — its reason/timestamp wins. + syncStatus, missingSince := "ok", r.ItemMissingSince + if r.ItemSyncStatus != nil { + syncStatus = *r.ItemSyncStatus + } + if r.PackageSyncStatus != "" && r.PackageSyncStatus != "ok" { + syncStatus, missingSince = r.PackageSyncStatus, r.PackageMissingSince + } + dto.SyncStatus = syncStatus + dto.MissingSince = formatTimePtr(missingSince) dto.MatchStatus = ReturnMatchFilterUnmatched if r.MatchID != nil { dto.MatchID = *r.MatchID @@ -306,6 +336,8 @@ func (h Handler) ListReturnPackages(c *gin.Context) { "p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " + "i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " + "i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced, " + + "i.sync_status as item_sync_status, i.missing_since as item_missing_since, " + + "p.sync_status as package_sync_status, p.missing_since as package_missing_since, " + "m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code", ).Order("p.create_time desc, i.id asc"). Offset((page - 1) * pageSize).Limit(pageSize) diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index 7d419ca..38cec1a 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -40,6 +40,13 @@ type Report struct { RunID uint64 TotalPages, Read, Created, Updated, Skipped, Failed int Status string + // 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. + MissingMarked int } // knownClaimStatuses lists the status values the sync code currently @@ -185,6 +192,13 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro }() seen := map[string]bool{} var firstWriteErr error + // complete tracks whether the page walk ended NATURALLY (empty page, + // short page, or reaching p.Pages) as opposed to the duplicate- + // fingerprint break or MaxPages exhaustion (#338): only a naturally + // complete run is trusted to mark absent items/packages "missing" below, + // since a duplicate/MaxPages stop means the walk never actually finished + // seeing everything yeeke currently has. + complete := false for page := 1; page <= s.cfg.MaxPages; page++ { var p yeekeclient.ReturnPage var e error @@ -211,6 +225,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } rep.TotalPages = page if len(p.Records) == 0 { + complete = true break } finger := pageFingerprint(p) @@ -238,9 +253,11 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } } if len(p.Records) < s.cfg.PageSize { + complete = true break } if p.Pages > 0 && page >= p.Pages { + complete = true break } } @@ -253,9 +270,73 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro runErr = errors.New(errMsg) } } + // #338: only a naturally complete run with zero write failures is + // trusted to mark items/packages the sync no longer sees as "missing". + if complete && rep.Failed == 0 && rep.Status == "succeeded" { + marked, valveReason, mErr := s.markMissing(ctx, r) + switch { + case mErr != nil: + errMsg = truncateRunError(fmt.Sprintf("标记 yeeke 退货不可用失败:%v", mErr)) + case valveReason != "": + errMsg = truncateRunError(valveReason) + default: + rep.MissingMarked = marked + } + } return rep, nil } +// markMissing implements #338's completion-triggered availability flip: any +// yeeke_return_item/yeeke_return_package row still marked sync_status="ok" +// but whose last_synced_at predates this run's start was not touched by +// this (complete, failure-free) run's upsert calls, meaning yeeke no longer +// reports it. Both tables are flipped to "missing" with missing_since +// stamped, in one transaction, so a reader never observes the item flipped +// without its package (or vice versa). Rows are never deleted. +// +// Safety valve: if the number of items that would be marked exceeds 20% of +// the items currently "ok", nothing is marked and valveReason explains why +// (the run itself still finishes as "succeeded" — this is a caution, not a +// sync failure). +func (s *Service) markMissing(ctx context.Context, r *models.YeekeSyncRun) (marked int, valveReason string, err error) { + var totalOkItems int64 + if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("sync_status = ?", "ok").Count(&totalOkItems).Error; e != nil { + return 0, "", e + } + var candidateItems int64 + if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Count(&candidateItems).Error; e != nil { + return 0, "", e + } + if candidateItems == 0 { + return 0, "", nil + } + // candidateItems/totalOkItems > 20% <=> candidateItems*5 > totalOkItems + // (integer-only, avoids float rounding). + if totalOkItems > 0 && candidateItems*5 > totalOkItems { + return 0, fmt.Sprintf( + "未标记 yeeke 退货不可用:待标记 %d 条超过当前可用退货商品 %d 条的 20%% 安全阈值,需人工核查后再处理", + candidateItems, totalOkItems, + ), nil + } + now := time.Now().UTC() + txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if e := tx.Model(&models.YeekeReturnItem{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; e != nil { + return e + } + return tx.Model(&models.YeekeReturnPackage{}). + Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt). + Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error + }) + if txErr != nil { + return 0, "", txErr + } + return int(candidateItems), "", nil +} + // truncateRunError keeps error_message inside the column's size limit. It // never includes request bodies or headers, so it cannot leak a captcha, // token or credential: every error path above passes only Go error text from @@ -304,7 +385,10 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool return e } } else { - vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"} + // #338: missing_since is reset to NULL whenever a package + // reappears in a sync so it recovers cleanly, whatever its prior + // sync_status was. + vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&row).Updates(vals).Error; e != nil { return e } @@ -327,7 +411,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool case ie != nil: return ie default: - 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"} + 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 } diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go new file mode 100644 index 0000000..2e95322 --- /dev/null +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -0,0 +1,391 @@ +package yeeke + +import ( + "context" + "fmt" + "go-admin/app/goauto/models" + "go-admin/app/goauto/yeekeclient" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fivePackagesFirstReport builds a first-sync page of 5 distinct packages +// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%, +// the safety-valve boundary, which is not ">20%") still marks it missing. +func fivePackagesFirstReport() []string { + recs := make([]string, 0, 5) + for i := 1; i <= 5; i++ { + id := fmt.Sprintf("p%d", i) + recs = append(recs, record(id, "i", "v"+id, 1)) + } + return recs +} + +// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an +// earlier sync of 5 packages; a later, naturally complete run only reports +// the other 4 (p2..p5). p1's item and package must both flip to +// sync_status="missing" with missing_since set; p2..p5 must stay "ok". +func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.Status != "succeeded" { + t.Fatalf("rep=%+v", rep) + } + if rep.MissingMarked != 1 { + t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked) + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "missing" || p1.MissingSince == nil { + t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1) + } + var i1 models.YeekeReturnItem + if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil { + t.Fatal(e) + } + if i1.SyncStatus != "missing" || i1.MissingSince == nil { + t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2) + } +} + +// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed". +// Nothing must be marked missing even though p1 (from an earlier run) is +// absent from this run's (incomplete) output. +func TestFailedPageStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1, Retry: 0}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2)) + return + } + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + if _, err := s.Sync(context.Background(), "manual"); err == nil { + t.Fatal("expected the second run (failed page 2) to error") + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "ok" || p1.MissingSince != nil { + t.Fatalf("p1 must stay ok after a failed page, got %+v", p1) + } +} + +// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends +// with an error and must not mark anything missing. +func TestCtxCancelStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1, Retry: 0}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + ctx, cancel := context.WithCancel(context.Background()) + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + cancel() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2)) + return + } + http.Error(w, "should not be reached", http.StatusInternalServerError) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + if _, err := s.Sync(ctx, "manual"); err == nil { + t.Fatal("expected a context-cancellation error") + } + + var p1 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil { + t.Fatal(e) + } + if p1.SyncStatus != "ok" || p1.MissingSince != nil { + t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1) + } +} + +// TestDuplicateFingerprintStopsMarking: the walk ends via the +// duplicate-page break, not a natural stop, so nothing must be marked even +// though only p1 (not p2 from an earlier sync) is ever reported. +func TestDuplicateFingerprintStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync (seeds p2): %v", err) + } + srv1.Close() + + // Second run: page 1 returns p1, but the server (mis)reports pages=5 and + // then serves the exact same page again, triggering the duplicate break + // before ever reaching a natural stop. + body := page([]string{record("p1", "i", "v1", 1)}, 10, 5) + var calls int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2) + } +} + +// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any +// natural stop condition being hit, so nothing must be marked. +func TestMaxPagesExhaustionStopsMarking(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 1}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync (seeds p2): %v", err) + } + srv1.Close() + + // Second run: every page returns a distinct full page (never short, + // never empty, pages always reported far beyond MaxPages), so the walk + // only stops because MaxPages is exhausted. + var n int32 + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k := atomic.AddInt32(&n, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + s.cfg.MaxPages = 2 + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked) + } + + var p2 models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil { + t.Fatal(e) + } + if p2.SyncStatus != "ok" || p2.MissingSince != nil { + t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2) + } +} + +// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a +// complete follow-up run only reports 1 of them (2 of 3 = 67% would be +// marked, well over the 20% threshold). Nothing must be marked, and the +// run's error_message must explain why. +func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1), record("p2", "i", "v2", 1), record("p3", "i", "v3", 1)}, 3, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("first sync: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1)) + })) + defer srv2.Close() + s.client, _ = yeekeclient.New(srv2.URL) + rep, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("second sync: %v", err) + } + if rep.Status != "succeeded" { + t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status) + } + if rep.MissingMarked != 0 { + t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked) + } + var run models.YeekeSyncRun + if e := db.First(&run, rep.RunID).Error; e != nil { + t.Fatal(e) + } + if !strings.Contains(run.ErrorMessage, "20%") { + t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage) + } + + for _, ext := range []string{"p2", "p3"} { + var p models.YeekeReturnPackage + if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil { + t.Fatal(e) + } + if p.SyncStatus != "ok" || p.MissingSince != nil { + t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p) + } + } +} + +// TestReappearingRecordRecoversFromMissing: a package/item marked missing by +// an earlier complete run reappears in a later sync and must recover to +// sync_status="ok" with missing_since cleared. +func TestReappearingRecordRecoversFromMissing(t *testing.T) { + db := testDB(t) + c, _ := yeekeclient.New("http://unused.invalid") + s := NewService(db, c, Config{PageSize: 10}) + + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + s.client, _ = yeekeclient.New(srv1.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("run 1: %v", err) + } + srv1.Close() + + time.Sleep(1100 * time.Millisecond) + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1)) + })) + s.client, _ = yeekeclient.New(srv2.URL) + rep2, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("run 2: %v", err) + } + if rep2.MissingMarked != 1 { + t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked) + } + srv2.Close() + + var p1 models.YeekeReturnPackage + db.Where("external_id = ?", "p1").First(&p1) + if p1.SyncStatus != "missing" || p1.MissingSince == nil { + t.Fatalf("p1 must be missing before recovery, got %+v", p1) + } + + srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1)) + })) + defer srv3.Close() + s.client, _ = yeekeclient.New(srv3.URL) + if _, err := s.Sync(context.Background(), "manual"); err != nil { + t.Fatalf("run 3 (recovery): %v", err) + } + + // 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 + // row's column is NULL, so reusing the earlier struct would misreport a + // stale MissingSince even though the row itself recovered correctly. + var recovered models.YeekeReturnPackage + if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil { + t.Fatal(e) + } + if recovered.SyncStatus != "ok" || recovered.MissingSince != nil { + t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered) + } + var i1 models.YeekeReturnItem + if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil { + t.Fatal(e) + } + if i1.SyncStatus != "ok" || i1.MissingSince != nil { + t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go b/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go new file mode 100644 index 0000000..3be2cbc --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800900000_return_missing.go @@ -0,0 +1,28 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338 scope addition: adds the nullable missing_since column to +// yeeke_return_package and yeeke_return_item (models.YeekeReturnPackage / +// models.YeekeReturnItem, registered in migrations.MigratedModels) on +// databases whose earlier versions are already recorded in sys_migration. +// AutoMigrate alone never reaches an existing database without a version +// file like this one being run by the migrate command. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMissing) +} +func migrateReturnMissing(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/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index af537fa..e9496bf 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -62,6 +62,7 @@
销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}
+

yeeke 退货商品

+ {{ matchDetail.data.yeeke.orderSn }} {{ matchDetail.data.yeeke.itemId }} @@ -838,6 +840,7 @@ export default { .link{color:#1677ff;cursor:pointer} .shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .notice{margin-bottom:16px} +.return-missing-alert{margin:6px 0} .compact-notice{margin-bottom:12px} .purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle} .spec-match-summary{gap:8px 24px} diff --git a/web/src/views/goauto/yeeke-returns/index.vue b/web/src/views/goauto/yeeke-returns/index.vue index bb6cb4d..fe2871c 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -66,6 +66,12 @@ {{ claimStatusMeta(row.claimStatus).label }} + + +