Compare commits
2
Commits
8b4db9c89e
...
ddd875d989
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd875d989 | ||
|
|
ff6e87649c |
@@ -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" }
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,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
|
||||
@@ -288,7 +291,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
|
||||
@@ -462,6 +465,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) {
|
||||
@@ -491,32 +500,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 {
|
||||
@@ -524,6 +543,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
|
||||
@@ -557,6 +578,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 {
|
||||
@@ -599,10 +625,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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -62,6 +62,7 @@
|
||||
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" 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>
|
||||
@@ -221,6 +222,7 @@
|
||||
</div>
|
||||
<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-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>
|
||||
@@ -871,6 +873,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}
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="yeeke 可用性" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.syncStatus === 'missing'" type="danger" effect="plain">不可用(yeeke 列表中已不存在)</el-tag>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
|
||||
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
|
||||
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||
|
||||
Reference in New Issue
Block a user