diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 762a037..4c44f62 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -14,6 +14,7 @@ import ( goautopurchase "go-admin/app/goauto/purchase" goautopurchaserule "go-admin/app/goauto/purchaserule" goautoreplacement "go-admin/app/goauto/replacement" + goautoreturnmatch "go-admin/app/goauto/returnmatch" goautorule "go-admin/app/goauto/rule" goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" goautosybimport "go-admin/app/goauto/sybimport" @@ -71,4 +72,5 @@ func InitRouter() { goautosybshop.InitRouter(r, authMiddleware) goautosybproductfilter.InitRouter(r, authMiddleware) goautoyeeke.InitRouter(r, authMiddleware) + goautoreturnmatch.InitRouter(r, authMiddleware) } diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go index 72cc71b..d48cd7c 100644 --- a/server/app/goauto/access/modules.go +++ b/server/app/goauto/access/modules.go @@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string { return ModuleYeekeSyncRuns case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"): return ModuleYeekeReturns + case strings.HasPrefix(path, "/api/admin/v1/return-matches"): + return ModuleSYBProducts default: return "" } diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 2d68cfd..bd76654 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -135,6 +135,15 @@ var AdminAPIs = []APIPermission{ {"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true}, {"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true}, + {"查看退货匹配", "/api/admin/v1/return-matches", "GET", true}, + {"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true}, + {"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true}, + {"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true}, + {"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true}, + {"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true}, + {"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true}, + {"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true}, + {"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true}, {"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false}, {"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index e9aeb32..443a094 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -79,6 +79,9 @@ func MigratedModels() []any { &models.PDDProductReplacement{}, &models.PDDProductReplacementItem{}, &models.PDDProductReplacementWorkerLease{}, + &models.ReturnMatch{}, + &models.ReturnMatchLog{}, + &models.ReturnMatchBatch{}, } } diff --git a/server/app/goauto/models/return_match.go b/server/app/goauto/models/return_match.go new file mode 100644 index 0000000..baab66c --- /dev/null +++ b/server/app/goauto/models/return_match.go @@ -0,0 +1,71 @@ +package models + +import "time" + +// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so +// the return can be reused instead of buying the item again on PDD. A match +// is "active" (still blocking purchase creation and occupying the return) +// while Status is matched or confirmed; cancelling clears both Active* +// columns so the unique index frees up both sides for a future rematch +// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许). +// +// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique- +// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase: +// MySQL unique indexes allow any number of NULL rows, so only the currently +// active match for a given side occupies the slot, and a concurrent second +// insert for the same side fails the unique constraint instead of double- +// booking it. +const ( + ReturnMatchStatusMatched = "matched" + ReturnMatchStatusConfirmed = "confirmed" + ReturnMatchStatusCancelled = "cancelled" +) + +type ReturnMatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + + SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"` + YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"` + + // ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above + // while Status is matched/confirmed, and are cleared to NULL on cancel. + ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"` + ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"` + + Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"` + + // Snapshots of the normalized spec text on both sides at match time, kept + // for audit and for the compare screen even if the source rows change + // later. + SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"` + YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"` + NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"` + + // PreviousProcessStage records what the SYB product's computed process + // stage was immediately before this match, so cancel can report/restore + // context; the actual restore is a pure recomputation (no stage is + // stored on syb_product), this is for audit/display only. + PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"` + + DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"` + + Remark string `json:"remark" gorm:"size:500;not null;default:''"` + + MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"` + MatchedAt time.Time `json:"matchedAt"` + ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"` + ConfirmedAt *time.Time `json:"confirmedAt,omitempty"` + CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (ReturnMatch) TableName() string { return "return_match" } + +// IsActive reports whether the match currently blocks purchase creation and +// occupies its return item. +func (m ReturnMatch) IsActive() bool { + return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed +} diff --git a/server/app/goauto/models/return_match_batch.go b/server/app/goauto/models/return_match_batch.go new file mode 100644 index 0000000..e0ea492 --- /dev/null +++ b/server/app/goauto/models/return_match_batch.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it, +// which SYB products were submitted and the per-product outcome, so a later +// question like "why did only N of my selection match" can be answered from +// data instead of reconstructing the page selection. ItemsJSON holds the +// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/ +// matchId) in submission order. +type ReturnMatchBatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Operator string `json:"operator" gorm:"size:64;not null;default:'';index"` + SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"` + MatchedCount int `json:"matchedCount" gorm:"not null;default:0"` + SkippedCount int `json:"skippedCount" gorm:"not null;default:0"` + ItemsJSON string `json:"-" gorm:"type:longtext;not null"` + // Error is non-empty when the batch aborted part-way; ItemsJSON then + // holds only the rows processed before the failure. + Error string `json:"error" gorm:"size:500;not null;default:''"` + + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +func (ReturnMatchBatch) TableName() string { return "return_match_batch" } diff --git a/server/app/goauto/models/return_match_log.go b/server/app/goauto/models/return_match_log.go new file mode 100644 index 0000000..4a99aad --- /dev/null +++ b/server/app/goauto/models/return_match_log.go @@ -0,0 +1,28 @@ +package models + +import "time" + +// ReturnMatchLog is #338's dedicated operation log for the return-matching +// feature. The framework's generic sys_opera_log records raw HTTP +// request/response per call and is not queryable per match id nor does it +// carry a stable action/operator/detail shape, so this table is written +// explicitly, in the same transaction as the action it records (match / +// confirm / cancel / remark). +const ( + ReturnMatchLogActionMatched = "matched" + ReturnMatchLogActionConfirmed = "confirmed" + ReturnMatchLogActionCancelled = "cancelled" + ReturnMatchLogActionRemark = "remark" +) + +type ReturnMatchLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + MatchID uint64 `json:"matchId" gorm:"not null;index"` + Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"` + Operator string `json:"operator" gorm:"size:64;not null;default:''"` + Detail string `json:"detail" gorm:"size:1000;not null;default:''"` + + CreatedAt time.Time `json:"createdAt"` +} + +func (ReturnMatchLog) TableName() string { return "return_match_log" } diff --git a/server/app/goauto/models/yeeke.go b/server/app/goauto/models/yeeke.go index a158287..51e94fb 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,32 +61,40 @@ 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" } type YeekeSyncRun struct { - ID uint64 `gorm:"primaryKey;autoIncrement"` - Status string `gorm:"size:32;not null;index"` - Trigger string `gorm:"size:32;not null;index"` - TotalPages int `gorm:"not null;default:0"` - ReadCount int `gorm:"not null;default:0"` - CreatedCount int `gorm:"not null;default:0"` - UpdatedCount int `gorm:"not null;default:0"` - SkippedCount int `gorm:"not null;default:0"` - FailedCount int `gorm:"not null;default:0"` - ErrorMessage string `gorm:"size:1000;not null;default:''"` - StartedAt time.Time `gorm:"not null"` - FinishedAt *time.Time - LastSuccessAt *time.Time - ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` - LeaseOwner string `gorm:"size:128;not null;default:''"` - LeaseExpiresAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID uint64 `gorm:"primaryKey;autoIncrement"` + Status string `gorm:"size:32;not null;index"` + Trigger string `gorm:"size:32;not null;index"` + TotalPages int `gorm:"not null;default:0"` + ReadCount int `gorm:"not null;default:0"` + CreatedCount int `gorm:"not null;default:0"` + UpdatedCount int `gorm:"not null;default:0"` + SkippedCount int `gorm:"not null;default:0"` + FailedCount int `gorm:"not null;default:0"` + // #338: items flipped to "missing" / back to "ok" by this run. + MissingMarkedCount int `gorm:"not null;default:0"` + RecoveredCount int `gorm:"not null;default:0"` + ErrorMessage string `gorm:"size:1000;not null;default:''"` + StartedAt time.Time `gorm:"not null"` + FinishedAt *time.Time + LastSuccessAt *time.Time + ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"` + LeaseOwner string `gorm:"size:128;not null;default:''"` + LeaseExpiresAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time } func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 0ff0243..67f631d 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -142,6 +142,11 @@ type batchPreviewDataset struct { // 见 #289 与 sybimport.RawSpecHalves。 collapsedColorByShopee map[uint64]map[string]bool collapsedSizeByShopee map[uint64]map[string]bool + // activeReturnMatchBySYB is #338's return-match stage input: a non-zero + // entry means the SYB product currently has an active (matched or + // confirmed) return_match row, which forces the 退货待确认/已用退货 + // stages regardless of what the rest of the pipeline would compute. + activeReturnMatchBySYB map[uint64]models.ReturnMatch } // loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries. @@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), collapsedColorByShopee: make(map[uint64]map[string]bool), collapsedSizeByShopee: make(map[uint64]map[string]bool), + activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba dataset.latestTaskBySYB[*task.SYBProductID] = task } } + // #338: load active return matches for these SYB products. This query + // only ever ADDS a stage override on top of the pre-#338 computation; an + // empty result set (the common case for products never matched) leaves + // every other branch of processStageFromDataset byte-for-byte unchanged + // (issue #338 acceptance item 11 regression). + var activeMatches []models.ReturnMatch + if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil { + return dataset, err + } + for _, m := range activeMatches { + dataset.activeReturnMatchBySYB[m.SYBProductID] = m + } return dataset, nil } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 3be3c96..64e65ad 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } // #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测), - // 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字; - // 只有新增的查询确实有界时才允许上调。 - if queries != 8 { - t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries) + // 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃 + // 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体 + // 数字;只有新增的查询确实有界时才允许上调。 + if queries != 9 { + t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", queries) } if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 { t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index ca70dc0..bcd668f 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -21,7 +21,13 @@ const ( ProcessStageTaskCreated = "task_created" ProcessStagePurchaseSucceeded = "purchase_succeeded" ProcessStageOrderReview = "order_review" - processActionOpenPDDLink = "open_pdd_link" + // ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages + // added by issue #338: a SYB product with an active return_match blocks + // purchase creation regardless of what stage it would otherwise compute + // to (see rejectIfActiveReturnMatch and processStageFromDataset below). + ProcessStageReturnPending = "return_pending" + ProcessStageReturnUsed = "return_used" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -35,6 +41,8 @@ var processStageLabels = map[string]string{ ProcessStageTaskCreated: "已创建任务", ProcessStagePurchaseSucceeded: "采购成功", ProcessStageOrderReview: "待人工核对", + ProcessStageReturnPending: "退货待确认", + ProcessStageReturnUsed: "已用退货", } type ProcessStageResult struct { @@ -132,6 +140,17 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat } } + // #338: an active return match overrides the normal computation below — + // it only ever fires for a SYB product that has one, so with zero + // matches (the pre-#338 default) this branch is a no-op and every stage + // below is unchanged (acceptance item 11 regression). + if match, ok := dataset.activeReturnMatchBySYB[id]; ok { + if match.Status == models.ReturnMatchStatusConfirmed { + return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match") + } + return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match") + } + syb, ok := dataset.sybByID[id] if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go new file mode 100644 index 0000000..812375a --- /dev/null +++ b/server/app/goauto/purchase/return_match_block.go @@ -0,0 +1,30 @@ +package purchase + +import ( + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// rejectIfActiveReturnMatch implements issue #338's purchase-creation block: +// a SYB product with an active (matched or confirmed) return_match row must +// not get a NEW purchase task created for it (single or batch — BatchCreate +// calls s.Create per row, so this single check point covers both paths and +// batch creation simply skips the row via the normal createErr handling, +// issue #338 acceptance item 5). Resuming/reparsing an existing task is a +// different code path and is not affected. +// +// On any query error this returns the error (never nil) so a DB failure can +// never silently allow a purchase for an already-matched product (issue +// #338 acceptance item 11). +func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error { + var match models.ReturnMatch + err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error + if err == nil { + return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务") + } + if err == gorm.ErrRecordNotFound { + return nil + } + return internal(err) +} diff --git a/server/app/goauto/purchase/return_match_block_test.go b/server/app/goauto/purchase/return_match_block_test.go new file mode 100644 index 0000000..5ea3e70 --- /dev/null +++ b/server/app/goauto/purchase/return_match_block_test.go @@ -0,0 +1,145 @@ +package purchase + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #338 acceptance item 5/6/11: a SYB product with an active return match +// must be rejected for NEW single-task creation, batch creation must skip +// only that row (not fail the whole batch), and with zero matches (the +// default) behavior must be byte-for-byte unchanged. + +func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection, got success") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection for confirmed match too") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("cancelled match must not block creation: %v", err) + } +} + +// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5 +// "batch creation must skip just those rows" regression: one blocked SYB +// product and one clean one in the same batch call. +func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f1 := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f1.pdd.ID) + + // second, independent eligible SYB product sharing the same shopee/pdd + // chain shape but its own row. + syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := db.Create(&syb2).Error; err != nil { + t.Fatal(err) + } + + match := models.ReturnMatch{ + SYBProductID: f1.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{ + RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID, + }) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if resp.CreatedCount != 1 || resp.FailedCount != 1 { + t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items) + } + for _, item := range resp.Items { + if item.SYBProductID == f1.syb.ID { + if item.Created { + t.Fatalf("matched row must not be created: %+v", item) + } + if item.ReasonCode != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched reason, got %+v", item) + } + } + if item.SYBProductID == syb2.ID && !item.Created { + t.Fatalf("clean row must still be created: %+v", item) + } + } +} + +// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11 +// regression: with zero return_match rows anywhere, creation behaves exactly +// as it did before #338. +func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("zero-match baseline must succeed unchanged: %v", err) + } +} + +func asServiceError(err error) (*ServiceError, bool) { + se, ok := err.(*ServiceError) + return se, ok +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 7b4ffa9..fb25908 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -178,6 +178,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil { return notFound(err, "顺云宝商品不存在") } + if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil { + return err + } if syb.ShopeeProductID == nil { return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 2a5f780..332e71e 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -36,7 +36,12 @@ const ( CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING" CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID" CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING" - CodeInternal = "INTERNAL_ERROR" + // CodeReturnMatched is returned when a SYB product has an active (matched + // or confirmed) return match: issue #338 blocks creating NEW purchase + // tasks for such a product until the match is cancelled. Resuming or + // reparsing an existing task is not affected. + CodeReturnMatched = "PURCHASE_RETURN_MATCHED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct { diff --git a/server/app/goauto/returnmatch/batch_record_test.go b/server/app/goauto/returnmatch/batch_record_test.go new file mode 100644 index 0000000..72cafc8 --- /dev/null +++ b/server/app/goauto/returnmatch/batch_record_test.go @@ -0,0 +1,80 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// #338: every 「匹配退货」 click is recorded with the submitted SYB products in +// order and each one's outcome, so "why did only some of my selection match" +// can be answered from data. +func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(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) + hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)) + miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + missingID := uint64(999999) + + submitted := []uint64{miss.ID, hit.ID, missingID} + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"}) + if err != nil { + t.Fatal(err) + } + + batches, err := s.ListBatches(context.Background(), 10, true) + if err != nil { + t.Fatal(err) + } + if len(batches) != 1 { + t.Fatalf("want exactly one batch record, got %d", len(batches)) + } + b := batches[0] + if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" { + t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch) + } + if len(b.Items) != len(submitted) { + t.Fatalf("want %d items, got %+v", len(submitted), b.Items) + } + for i, id := range submitted { + if b.Items[i].SYBProductID != id { + t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID) + } + } + if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible { + t.Fatalf("unexpected recorded outcomes: %+v", b.Items) + } + + got, err := s.GetBatch(context.Background(), b.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 3 { + t.Fatalf("GetBatch items: %+v", got.Items) + } + + // The list without items stays small. + light, err := s.ListBatches(context.Background(), 10, false) + if err != nil || len(light) != 1 || light[0].Items != nil { + t.Fatalf("list without items: %+v err=%v", light, err) + } +} + +// An empty submission is rejected before matching and leaves no record. +func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) { + db := testDB(t) + if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil { + t.Fatal(err) + } + var n int64 + db.Model(&models.ReturnMatchBatch{}).Count(&n) + if n != 0 { + t.Fatalf("empty submission recorded %d batches", n) + } +} diff --git a/server/app/goauto/returnmatch/candidate.go b/server/app/goauto/returnmatch/candidate.go new file mode 100644 index 0000000..3cbe66c --- /dev/null +++ b/server/app/goauto/returnmatch/candidate.go @@ -0,0 +1,112 @@ +package returnmatch + +import ( + "sort" + "time" +) + +// SYBCandidate is the pure, DB-free view of one selected SYB order product +// eligible to participate in this batch's matching (stage filter and "no +// active match" filter are applied by the caller before building this list; +// see issue #338 rule 1). +type SYBCandidate struct { + SYBProductID uint64 + ShopeeItemID string + TargetColor string + TargetSize string + CreatedAt time.Time +} + +// ReturnCandidate is the pure, DB-free view of one available yeeke return +// item (no active match, non-nil destroy deadline; the "deadline later than +// now" filter is applied by the caller — see BuildReturnPool below, or by +// the caller directly when it already filters in SQL). +type ReturnCandidate struct { + ReturnItemID uint64 + ItemID string + VariationName string + DestroyDeadline time.Time +} + +// SkipReason enumerates why a selected SYB candidate was not matched in this +// batch, for the batch-result dialog's per-row reasons (issue #338 prototype +// screen 2). +type SkipReason string + +const ( + SkipReasonNoCandidate SkipReason = "no_candidate" +) + +// MatchOutcome is one row of the pure matching result: either matched to a +// return item, or skipped with a reason. +type MatchOutcome struct { + SYBProductID uint64 + Matched bool + ReturnItemID uint64 + SkipReason SkipReason + NormalizedKey string + DestroyDeadline time.Time +} + +// SelectMatches implements issue #338 rules 2-6 purely in memory: +// - products are processed in the order given by the caller, which MUST be +// SYB created_at DESC (rule 4); this function does not itself sort by +// CreatedAt so a caller can supply a stable pre-sorted/tie-broken order. +// - a return is eligible only while its deadline is strictly after `now` +// (rule 2), matched on shopeeItemId + normalized spec text (rule 3); +// - once a return is used within this run it cannot be reused by a later +// product in the same run, on top of whatever was already occupied +// before the run started (rule 4, "已被占用的退货商品不再参与本轮后续 +// 及以后的匹配"); +// - when several candidates match the same product, the one with the +// earliest destroy deadline is chosen (rule 5); quantity is ignored +// entirely (rule 6). +func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.Time) []MatchOutcome { + // Group available (deadline > now) returns by matchKey = itemId + "|" + + // normalized spec text, sorted by deadline ascending so the first unused + // entry in each bucket is always the earliest deadline. + buckets := make(map[string][]ReturnCandidate) + for _, r := range returns { + if !r.DestroyDeadline.After(now) { + continue + } + key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName))) + buckets[key] = append(buckets[key], r) + } + for key := range buckets { + bucket := buckets[key] + sort.Slice(bucket, func(i, j int) bool { + return bucket[i].DestroyDeadline.Before(bucket[j].DestroyDeadline) + }) + buckets[key] = bucket + } + + used := make(map[uint64]bool) + outcomes := make([]MatchOutcome, 0, len(products)) + for _, p := range products { + key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize)))) + var picked *ReturnCandidate + for i := range buckets[key] { + cand := buckets[key][i] + if used[cand.ReturnItemID] { + continue + } + picked = &buckets[key][i] + break + } + if picked == nil { + outcomes = append(outcomes, MatchOutcome{SYBProductID: p.SYBProductID, Matched: false, SkipReason: SkipReasonNoCandidate, NormalizedKey: key}) + continue + } + used[picked.ReturnItemID] = true + outcomes = append(outcomes, MatchOutcome{ + SYBProductID: p.SYBProductID, Matched: true, ReturnItemID: picked.ReturnItemID, + NormalizedKey: key, DestroyDeadline: picked.DestroyDeadline, + }) + } + return outcomes +} + +func matchKey(itemID, normalizedSpec string) string { + return itemID + "|" + normalizedSpec +} diff --git a/server/app/goauto/returnmatch/candidate_test.go b/server/app/goauto/returnmatch/candidate_test.go new file mode 100644 index 0000000..be7fce4 --- /dev/null +++ b/server/app/goauto/returnmatch/candidate_test.go @@ -0,0 +1,149 @@ +package returnmatch + +import ( + "testing" + "time" +) + +func t1(offsetDays int) time.Time { + base := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) + return base.AddDate(0, 0, offsetDays) +} + +func TestSelectMatches_BasicMatch(t *testing.T) { + now := t1(0) + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if len(out) != 1 || !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("unexpected outcome: %+v", out) + } +} + +// TestSelectMatches_MultiColourSameOrderCrossPairing reproduces issue #338's +// documented real-world case: the same order buys 2+ colour variants of the +// same Shopee item, and each SYB row must pair with the return item of its +// OWN colour, never grab whichever candidate is available first. +func TestSelectMatches_MultiColourSameOrderCrossPairing(t *testing.T) { + now := t1(0) + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)}, + {SYBProductID: 2, ShopeeItemID: "100", TargetColor: "紫色", TargetSize: "M", CreatedAt: t1(-2)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 901, ItemID: "100", VariationName: "紫色,M【建議43-53公斤】", DestroyDeadline: t1(5)}, + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + got := map[uint64]uint64{} + for _, o := range out { + if !o.Matched { + t.Fatalf("expected all matched, got skip: %+v", o) + } + got[o.SYBProductID] = o.ReturnItemID + } + if got[1] != 900 { + t.Fatalf("white/2XL SYB product should match return 900 (white/2XL), got %d", got[1]) + } + if got[2] != 901 { + t.Fatalf("purple/M SYB product should match return 901 (purple/M), got %d", got[2]) + } +} + +func TestSelectMatches_ExpiredDeadlineExcluded(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(0)}, // not after now -> excluded + {ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(-1)}, // already past -> excluded + } + out := SelectMatches(products, returns, now) + if out[0].Matched { + t.Fatalf("expected no match for expired-only candidates, got %+v", out[0]) + } + if out[0].SkipReason != SkipReasonNoCandidate { + t.Fatalf("unexpected skip reason: %+v", out[0]) + } +} + +func TestSelectMatches_EarliestDeadlineFirst(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(30)}, + {ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(3)}, // soonest to expire, must win + {ReturnItemID: 902, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 901 { + t.Fatalf("expected earliest-deadline candidate 901, got %+v", out[0]) + } +} + +func TestSelectMatches_OccupiedWithinRunNotReused(t *testing.T) { + now := t1(0) + // Two products with the identical spec, only one return item available: + // processed in the given (already created_at DESC) order, the second + // product must NOT be able to reuse the same return item. + products := []SYBCandidate{ + {SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}, // newer, processed first (DESC) + {SYBProductID: 2, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-5)}, + } + returns := []ReturnCandidate{ + {ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}, + } + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("first (newer) product should win the only candidate: %+v", out[0]) + } + if out[1].Matched { + t.Fatalf("second product must not reuse the already-occupied return: %+v", out[1]) + } +} + +func TestSelectMatches_QuantityIgnored(t *testing.T) { + // SYBCandidate/ReturnCandidate deliberately carry no quantity field at + // all: this test documents that omission is intentional (issue #338 + // rule 6, quantity never participates in matching). + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched { + t.Fatalf("expected match regardless of any quantity mismatch: %+v", out[0]) + } +} + +func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("color-only spec should still match: %+v", out[0]) + } +} + +func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if !out[0].Matched || out[0].ReturnItemID != 900 { + t.Fatalf("size-only spec should still match: %+v", out[0]) + } +} + +func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) { + now := t1(0) + products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}} + returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "200", VariationName: "白色,L", DestroyDeadline: t1(10)}} + out := SelectMatches(products, returns, now) + if out[0].Matched { + t.Fatalf("different shopee item id must never match: %+v", out[0]) + } +} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go new file mode 100644 index 0000000..eb16380 --- /dev/null +++ b/server/app/goauto/returnmatch/handler.go @@ -0,0 +1,259 @@ +package returnmatch + +import ( + "errors" + "net/http" + "strconv" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "gorm.io/gorm" +) + +// Handler exposes the #338 return-matching admin surface: manual batch +// match, list/filter (shared by the SYB product page and the yeeke returns +// page), detail, confirm, cancel and remark. Everything here only reads or +// writes GoAuto's own database — no yeeke or SYB write call is made. +type Handler struct { + DB *gorm.DB +} + +func (h Handler) db(c *gin.Context) (*gorm.DB, bool) { + db := h.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return nil, false + } + return db, true +} + +func operatorFromContext(c *gin.Context) (string, string) { + claims := jwt.ExtractClaims(c) + role, _ := claims["rolekey"].(string) + username, _ := claims["username"].(string) + if username == "" { + username, _ = claims["userName"].(string) + } + return role, username +} + +// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase +// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync, +// sybimport.Handler.Import): trigger match, confirm, cancel and remark are +// writes and require it; the two list/detail read endpoints do not. +func requireCanPurchase(c *gin.Context) bool { + role, _ := operatorFromContext(c) + if role != "admin" && role != "purchaser" && role != "after_sales" { + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"}) + return false + } + return true +} + +type batchMatchBody struct { + SYBProductIDs []uint64 `json:"sybProductIds"` +} + +// BatchMatch is the「匹配退货」button: manual-only trigger for ticked rows. +func (h Handler) BatchMatch(c *gin.Context) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + var body batchMatchBody + if err := c.ShouldBindJSON(&body); err != nil || len(body.SYBProductIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "sybProductIds 不能为空"}) + return + } + _, operator := operatorFromContext(c) + resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": resp}) +} + +// List backs both the SYB product page's匹配列/筛选 and the yeeke returns +// page's占用状态筛选/列 (issue #338 pages section). +func (h Handler) List(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + filter := ListFilter{Status: c.Query("status")} + if raw := c.QueryArray("sybProductId"); len(raw) > 0 { + filter.SYBProductIDs = parseUint64List(raw) + } + if raw := c.QueryArray("yeekeReturnItemId"); len(raw) > 0 { + filter.YeekeReturnItemIDs = parseUint64List(raw) + } + rows, err := NewService(db).List(c.Request.Context(), filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}}) +} + +func (h Handler) Detail(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + detail, err := NewService(db).Detail(c.Request.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": detail}) +} + +func (h Handler) Confirm(c *gin.Context) { + h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) { + return s.Confirm(ctx.Request.Context(), id, operator) + }) +} + +func (h Handler) Cancel(c *gin.Context) { + h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) { + return s.Cancel(ctx.Request.Context(), id, operator) + }) +} + +func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint64, string) (models.ReturnMatch, error)) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + _, operator := operatorFromContext(c) + match, err := fn(NewService(db), c, id, operator) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + if errors.Is(err, errStateConflict) { + c.JSON(http.StatusConflict, gin.H{"code": "STATE_CONFLICT", "message": "匹配状态已变化,请刷新后重试"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) +} + +type remarkBody struct { + Remark string `json:"remark"` +} + +func (h Handler) Remark(c *gin.Context) { + if !requireCanPurchase(c) { + return + } + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"}) + return + } + var body remarkBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"}) + return + } + _, operator := operatorFromContext(c) + match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"}) + return + } + if errors.Is(err, errRemarkTooLong) { + c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}}) +} + +func parseUint64List(raw []string) []uint64 { + out := make([]uint64, 0, len(raw)) + for _, v := range raw { + if id, err := strconv.ParseUint(v, 10, 64); err == nil { + out = append(out, id) + } + } + return out +} + +// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted +// and matched counts); pass withItems=1 to include each row's outcome. +func (h Handler) ListBatches(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}}) +} + +// GetBatch returns one batch record with every submitted SYB product's outcome. +func (h Handler) GetBatch(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("batchId"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"}) + return + } + rec, err := NewService(db).GetBatch(c.Request.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec}) +} 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/normalize.go b/server/app/goauto/returnmatch/normalize.go new file mode 100644 index 0000000..0343c9a --- /dev/null +++ b/server/app/goauto/returnmatch/normalize.go @@ -0,0 +1,119 @@ +// Package returnmatch implements the #338 return-matching algorithm: pairing +// a SYB order product with an available yeeke return item that is the same +// Shopee item with the same spec, so the return can be reused instead of +// buying the item again. This file holds only pure, DB-free functions so the +// matching rules can be unit tested directly against the 840 real pairs used +// to design the normalization rule (see issue #338 "当前事实"). +package returnmatch + +import "strings" + +// bracketPairs lists every bracket style issue #338 requires stripped, along +// with its contained content: 【】()() and []. +var bracketPairs = []struct{ open, close rune }{ + {'【', '】'}, + {'(', ')'}, + {'(', ')'}, + {'[', ']'}, +} + +// stripBracketedContent removes every bracket-delimited span (any style in +// bracketPairs) and its contents. Unmatched opening brackets discard the +// remainder of the string from that point, which is safe here because SYB +// and yeeke free-text specs never rely on a trailing unmatched bracket to +// carry meaningful spec content. +func stripBracketedContent(s string) string { + var b strings.Builder + depth := 0 + for _, r := range s { + isOpen, isClose := false, false + for _, pair := range bracketPairs { + if r == pair.open { + isOpen = true + } + if r == pair.close { + isClose = true + } + } + switch { + case isOpen: + depth++ + case isClose: + if depth > 0 { + depth-- + } + case depth == 0: + b.WriteRune(r) + } + } + return b.String() +} + +// fullwidthToHalfwidth converts fullwidth ASCII forms (U+FF01-U+FF5E) and the +// fullwidth space (U+3000) to their halfwidth equivalents, leaving CJK +// characters (colors like 紫色) untouched. +func fullwidthToHalfwidth(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r == ' ': + b.WriteRune(' ') + case r >= '!' && r <= '~': + b.WriteRune(r - 0xFEE0) + default: + b.WriteRune(r) + } + } + return b.String() +} + +// Normalize implements issue #338's spec-text normalization rule: strip +// bracketed remarks (【】()()[] and their content), strip whitespace, +// convert fullwidth characters to halfwidth, and lowercase. It is used on +// both the SYB side (target_color + "," + target_size) and the yeeke side +// (variation_name) so the two can be compared for equality. +func Normalize(raw string) string { + s := stripBracketedContent(raw) + s = fullwidthToHalfwidth(s) + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if !isSpace(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func isSpace(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '\f', '\v', ' ': + return true + } + return false +} + +// SYBSpecText builds the SYB-side comparable spec text from the parser's +// target_color/target_size fields, per issue #338's data design. Only +// non-empty parts are joined so a single-dimension spec (color-only or +// size-only) doesn't pick up a stray leading/trailing comma that would +// prevent it from normalizing equal to the yeeke side's single-value +// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,"). +func SYBSpecText(targetColor, targetSize string) string { + parts := make([]string, 0, 2) + if targetColor != "" { + parts = append(parts, targetColor) + } + if targetSize != "" { + parts = append(parts, targetSize) + } + return strings.Join(parts, ",") +} + +// trimCommas removes leading/trailing commas left over after normalization +// (e.g. a source value that itself started or ended with a comma). Used only +// when building the matching key, so exact-text comparisons/tests elsewhere +// are unaffected. +func trimCommas(s string) string { + return strings.Trim(s, ",") +} diff --git a/server/app/goauto/returnmatch/normalize_test.go b/server/app/goauto/returnmatch/normalize_test.go new file mode 100644 index 0000000..8a9a798 --- /dev/null +++ b/server/app/goauto/returnmatch/normalize_test.go @@ -0,0 +1,78 @@ +package returnmatch + +import "testing" + +func TestNormalize(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"square brackets with content", "白色,2XL【建議65-75公斤】", "白色,2xl"}, + {"chinese parens", "紫色,M(建議43-53公斤)", "紫色,m"}, + {"ascii parens", "紫色,M (建議43-53公斤)", "紫色,m"}, + {"latin square brackets", "紫色,M [建議43-53公斤]", "紫色,m"}, + {"internal whitespace stripped", "紫色, M 【建議43-53公斤】", "紫色,m"}, + {"fullwidth digits and letters", "白色,2XL", "白色,2xl"}, + {"no bracket, plain", "黑色,L", "黑色,l"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Normalize(tc.in) + if got != tc.want { + t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeMatchesRealPairSample(t *testing.T) { + // Representative real pairs from issue #338's local 840-pair backtest + // note: SYB target_color+","+target_size vs yeeke variation_name should + // normalize equal once bracketed remarks are stripped. + pairs := []struct{ syb, yeeke string }{ + {SYBSpecText("白色", "2XL"), "白色,2XL【建議65-75公斤】"}, + {SYBSpecText("紫色", "M"), "紫色,M【建議43-53公斤】"}, + {SYBSpecText("黑色", "L"), "黑色,L"}, + } + for _, p := range pairs { + if Normalize(p.syb) != Normalize(p.yeeke) { + t.Fatalf("expected normalized equality: syb=%q (%q) yeeke=%q (%q)", + p.syb, Normalize(p.syb), p.yeeke, Normalize(p.yeeke)) + } + } +} + +func TestSYBSpecText(t *testing.T) { + if got := SYBSpecText("白色", "2XL"); got != "白色,2XL" { + t.Fatalf("got %q", got) + } +} + +func TestSYBSpecText_SingleDimension(t *testing.T) { + if got := SYBSpecText("黑色", ""); got != "黑色" { + t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色") + } + if got := SYBSpecText("", "L"); got != "L" { + t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L") + } + if got := SYBSpecText("", ""); got != "" { + t.Fatalf("both empty: got %q, want empty", got) + } +} + +func TestTrimCommas(t *testing.T) { + cases := map[string]string{ + "黑色,": "黑色", + ",黑色": "黑色", + ",黑色,": "黑色", + "黑色": "黑色", + "": "", + } + for in, want := range cases { + if got := trimCommas(in); got != want { + t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/server/app/goauto/returnmatch/router.go b/server/app/goauto/returnmatch/router.go new file mode 100644 index 0000000..c0ebae4 --- /dev/null +++ b/server/app/goauto/returnmatch/router.go @@ -0,0 +1,24 @@ +package returnmatch + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +// InitRouter mounts the #338 return-matching admin surface. List/detail are +// readable by any authenticated admin user; the write actions (batch match, +// confirm, cancel, remark) additionally require admin/purchaser/after-sales via +// requireCanPurchase, same gate as yeeke.Handler.TriggerSync. +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc()) + group.GET("", handler.List) + // Static batch routes are registered before /:id so they never parse as a match id. + group.GET("/batches", handler.ListBatches) + group.GET("/batches/:batchId", handler.GetBatch) + group.GET("/:id", handler.Detail) + group.POST("/batch-match", handler.BatchMatch) + group.POST("/:id/confirm", handler.Confirm) + group.POST("/:id/cancel", handler.Cancel) + group.POST("/:id/remark", handler.Remark) +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go new file mode 100644 index 0000000..f20adec --- /dev/null +++ b/server/app/goauto/returnmatch/service.go @@ -0,0 +1,638 @@ +package returnmatch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + "unicode/utf8" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchase" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func clauseLockUpdate() clause.Locking { return clause.Locking{Strength: "UPDATE"} } + +// participatingStages is issue #338 rule 1's "待采购范围": 待人工处理 is +// explicitly excluded (already matched, per the confirmed rule), as are the +// stages that mean a task is already in flight or a match already exists. +var participatingStages = map[string]bool{ + purchase.ProcessStagePDDUnlinked: true, + purchase.ProcessStagePDDPending: true, + purchase.ProcessStagePDDCollecting: true, + purchase.ProcessStagePDDCollectionFail: true, + purchase.ProcessStageColorMapping: true, + purchase.ProcessStagePurchaseReady: true, +} + +type Service struct { + DB *gorm.DB + Now func() time.Time +} + +func NewService(db *gorm.DB) *Service { + return &Service{DB: db, Now: time.Now} +} + +// BatchMatchRequest/Result mirror the prototype's batch result dialog +// (screen 2): per-SYB-product outcome plus a reason bucket count. +type BatchMatchRequest struct { + SYBProductIDs []uint64 + Operator string +} + +type BatchMatchItem struct { + SYBProductID uint64 `json:"sybProductId"` + Matched bool `json:"matched"` + ReasonCode string `json:"reasonCode,omitempty"` + Reason string `json:"reason,omitempty"` + MatchID uint64 `json:"matchId,omitempty"` +} + +type BatchMatchResponse struct { + Items []BatchMatchItem `json:"items"` + ProcessedCount int `json:"processedCount"` + MatchedCount int `json:"matchedCount"` + SkippedCount int `json:"skippedCount"` +} + +const ( + ReasonStageIneligible = "stage_ineligible" + ReasonNoCandidate = "no_candidate" + ReasonConflict = "conflict" +) + +// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only +// ever called from the batch-match button (ticked rows) — no scheduler, no +// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务). +func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + resp, err := s.batchMatch(ctx, req) + if len(req.SYBProductIDs) > 0 { + // The batch record is written after the per-row transactions have + // committed, so a failure here must not turn an already-applied + // match into an error response; it is logged instead. + if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil { + log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr) + } + } + return resp, err +} + +// recordBatch stores one ReturnMatchBatch row describing this click of +// 「匹配退货」: submitted SYB products in order and each one's outcome. +func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error { + items := resp.Items + if items == nil { + items = []BatchMatchItem{} + } + raw, err := json.Marshal(items) + if err != nil { + return err + } + row := models.ReturnMatchBatch{ + Operator: req.Operator, + SubmittedCount: len(req.SYBProductIDs), + MatchedCount: resp.MatchedCount, + SkippedCount: resp.SkippedCount, + ItemsJSON: string(raw), + } + if batchErr != nil { + row.Error = truncateRunes(batchErr.Error(), 500) + } + return s.DB.WithContext(ctx).Create(&row).Error +} + +func truncateRunes(v string, max int) string { + r := []rune(v) + if len(r) <= max { + return v + } + return string(r[:max]) +} + +// BatchRecord is the API view of one ReturnMatchBatch row. +type BatchRecord struct { + models.ReturnMatchBatch + Items []BatchMatchItem `json:"items,omitempty"` +} + +// ListBatches returns the most recent batch records, newest first; items are +// only expanded when withItems is true to keep the list response small. +func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var rows []models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + out := make([]BatchRecord, 0, len(rows)) + for _, row := range rows { + rec := BatchRecord{ReturnMatchBatch: row} + if withItems { + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +// GetBatch returns one batch record with its items expanded. +func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) { + var row models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil { + return BatchRecord{}, err + } + rec := BatchRecord{ReturnMatchBatch: row} + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return BatchRecord{}, err + } + return rec, nil +} + +func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))} + if len(req.SYBProductIDs) == 0 { + return resp, nil + } + + var sybProducts []models.SYBProduct + if err := s.DB.WithContext(ctx).Where("id IN ?", req.SYBProductIDs).Find(&sybProducts).Error; err != nil { + return resp, err + } + sybByID := make(map[uint64]models.SYBProduct, len(sybProducts)) + for _, p := range sybProducts { + sybByID[p.ID] = p + } + + stages, err := purchase.NewService(s.DB).ProcessStages(ctx, req.SYBProductIDs) + if err != nil { + return resp, err + } + + now := s.Now() + eligible := make([]SYBCandidate, 0, len(req.SYBProductIDs)) + skipped := make(map[uint64]string) + for _, id := range req.SYBProductIDs { + syb, ok := sybByID[id] + if !ok { + skipped[id] = "SYB 商品不存在或已删除" + continue + } + stage := stages[id] + if !participatingStages[stage.Stage] { + skipped[id] = "该处理阶段不参与匹配:" + stage.Label + continue + } + eligible = append(eligible, SYBCandidate{ + SYBProductID: syb.ID, ShopeeItemID: syb.ShopeeItemID, + TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, CreatedAt: syb.CreatedAt, + }) + } + // Rule 4: process in SYB created_at DESC order. + sortSYBCandidatesDesc(eligible) + + returns, err := s.availableReturnPool(ctx) + if err != nil { + return resp, err + } + + outcomes := SelectMatches(eligible, returns, now) + outcomeBySYB := make(map[uint64]MatchOutcome, len(outcomes)) + for _, o := range outcomes { + outcomeBySYB[o.SYBProductID] = o + } + + for _, id := range req.SYBProductIDs { + if reason, isSkip := skipped[id]; isSkip { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: reason}) + resp.SkippedCount++ + continue + } + outcome, ok := outcomeBySYB[id] + if !ok || !outcome.Matched { + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonNoCandidate, Reason: "没有满足条件的退货商品"}) + resp.SkippedCount++ + continue + } + match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator) + if insertErr != nil { + if errors.Is(insertErr, errStageNoLongerEligible) { + // #338 review fix: the stage was re-checked under the same + // FOR UPDATE lock purchase.create takes, right before insert. + // A purchase task could have been created for this row + // between the outer screening pass above and this point; + // when that happens the product is no longer in a + // participating stage and this row is skipped, not matched. + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"}) + resp.SkippedCount++ + continue + } + if isUniqueConstraintErr(insertErr) { + // Rule 7 / acceptance item 9: a concurrent insert lost the + // race for either side — skip this row and report it, never + // fail the whole batch. + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonConflict, Reason: "并发匹配冲突,该商品或退货商品已被占用"}) + resp.SkippedCount++ + continue + } + return resp, insertErr + } + resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: true, MatchID: match.ID}) + resp.MatchedCount++ + } + resp.ProcessedCount = len(resp.Items) + return resp, nil +} + +func sortSYBCandidatesDesc(items []SYBCandidate) { + for i := 1; i < len(items); i++ { + for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- { + items[j], items[j-1] = items[j-1], items[j] + } + } +} + +// 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 + ItemID string + VariationName string + DestroyDeadLine *time.Time + } + err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i"). + 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 AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok"). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]ReturnCandidate, 0, len(rows)) + for _, r := range rows { + if r.DestroyDeadLine == nil { + continue + } + out = append(out, ReturnCandidate{ReturnItemID: r.ID, ItemID: r.ItemID, VariationName: r.VariationName, DestroyDeadline: *r.DestroyDeadLine}) + } + return out, nil +} + +// errStageNoLongerEligible is returned by matchOneWithLock when the SYB +// product's process stage, re-checked under lock immediately before insert, +// is no longer in participatingStages — e.g. a purchase task was created for +// it between BatchMatch's outer screening pass and this point (#338 review +// fix: race between matching and purchase creation). +var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching") + +// matchOneWithLock takes the SAME row lock purchase.Service.create takes on +// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the +// process stage inside that transaction via purchase.NewService(tx) before +// inserting the match, so a purchase task creation racing with this batch +// match can never both succeed: whichever gets the row lock first commits, +// and the other sees the now-current state (an active return_match row, or +// a task_created/order_review/purchase_succeeded stage) and is rejected/ +// skipped instead of double-committing an inconsistent state. +func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var syb models.SYBProduct + if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil { + return err + } + stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID}) + if err != nil { + return err + } + stage := stages[sybID] + if !participatingStages[stage.Stage] { + return errStageNoLongerEligible + } + var returnItem models.YeekeReturnItem + if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil { + return err + } + sybIDCopy := syb.ID + returnIDCopy := outcome.ReturnItemID + deadline := outcome.DestroyDeadline + now := s.Now() + match = models.ReturnMatch{ + SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy, + ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy, + Status: models.ReturnMatchStatusMatched, + SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize), + YeekeSpecText: returnItem.VariationName, + NormalizedKey: outcome.NormalizedKey, + PreviousProcessStage: stage.Stage, + MatchedBy: operator, + MatchedAt: now, + DestroyDeadlineSnapshot: &deadline, + } + if err := tx.Create(&match).Error; err != nil { + return err + } + logRow := models.ReturnMatchLog{ + MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator, + Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label), + } + return tx.Create(&logRow).Error + }) + return match, err +} + +func isUniqueConstraintErr(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate") +} + +// Confirm/Cancel/Remark implement the human-review half of issue #338. + +func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil { + return err + } + if match.Status != models.ReturnMatchStatusMatched { + return errStateConflict + } + now := s.Now() + match.Status = models.ReturnMatchStatusConfirmed + match.ConfirmedBy = operator + match.ConfirmedAt = &now + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error + }) + return match, err +} + +// Cancel restores the SYB product to purchasable (by clearing +// ActiveSYBProductID) and returns the return item to the eligible pool (by +// clearing ActiveYeekeReturnItemID), from either matched or confirmed state, +// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许. +func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) { + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil { + return err + } + if match.Status == models.ReturnMatchStatusCancelled { + return errStateConflict + } + now := s.Now() + match.Status = models.ReturnMatchStatusCancelled + match.ActiveSYBProductID = nil + match.ActiveYeekeReturnItemID = nil + match.CancelledBy = operator + match.CancelledAt = &now + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error + }) + return match, err +} + +// errRemarkTooLong is returned instead of silently truncating; remark is a +// varchar(500) column and MySQL VARCHAR length is a character count, so this +// checks runes, not bytes. +var errRemarkTooLong = errors.New("remark exceeds 500 characters") + +const maxRemarkLength = 500 + +func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) { + if utf8.RuneCountInString(remark) > maxRemarkLength { + return models.ReturnMatch{}, errRemarkTooLong + } + var match models.ReturnMatch + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&match, matchID).Error; err != nil { + return err + } + match.Remark = remark + if err := tx.Save(&match).Error; err != nil { + return err + } + return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error + }) + return match, err +} + +// List supports both admin pages' filter/column needs: by SYB product ids, +// by yeeke return item ids, or by status. +type ListFilter struct { + SYBProductIDs []uint64 + YeekeReturnItemIDs []uint64 + Status string +} + +// ListItem is one row of List's result: the match itself plus a few display +// fields the SYB product page's match column needs (thumbnail, return order +// sn, spec) so it never has to issue a second request per row. +type ListItem struct { + models.ReturnMatch + OrderSN string `json:"orderSn,omitempty"` + 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) { + q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{}) + if len(filter.SYBProductIDs) > 0 { + q = q.Where("syb_product_id IN ?", filter.SYBProductIDs) + } + if len(filter.YeekeReturnItemIDs) > 0 { + q = q.Where("yeeke_return_item_id IN ?", filter.YeekeReturnItemIDs) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + var rows []models.ReturnMatch + if err := q.Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + items := make([]ListItem, len(rows)) + returnIDs := make([]uint64, 0, len(rows)) + for i, r := range rows { + items[i] = ListItem{ReturnMatch: r} + returnIDs = append(returnIDs, r.YeekeReturnItemID) + } + if len(returnIDs) == 0 { + return items, nil + } + // 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 + 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, "+ + "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 + } + type extraFields struct { + OrderSN string + Image string + VariationName string + DestroyDeadLine *time.Time + SyncStatus string + MissingSince *time.Time + } + byID := make(map[uint64]extraFields, len(joined)) + for _, j := range joined { + // 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 { + items[i].OrderSN = extra.OrderSN + 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 +} + +// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare +// screen (screen 3): both sides' fields and images side by side, normalized +// spec text for both, and the operation log. +type SYBDetailView struct { + SYBProductID uint64 `json:"sybProductId"` + OrderCode string `json:"orderCode"` + ShopeeItemID string `json:"shopeeItemId"` + ShopName string `json:"shopName"` + ProductTitle string `json:"productTitle"` + TargetColor string `json:"targetColor"` + TargetSize string `json:"targetSize"` + Quantity int64 `json:"quantity"` + ImageURL string `json:"imageUrl,omitempty"` + CurrentStage string `json:"currentStage"` + CurrentStageLabel string `json:"currentStageLabel"` +} + +type YeekeDetailView struct { + YeekeReturnItemID uint64 `json:"yeekeReturnItemId"` + OrderSN string `json:"orderSn"` + ItemID string `json:"itemId"` + VariationID string `json:"variationId"` + ShopName string `json:"shopName"` + ItemName string `json:"itemName"` + VariationName string `json:"variationName"` + 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 { + Match models.ReturnMatch `json:"match"` + SYB *SYBDetailView `json:"syb,omitempty"` + Yeeke *YeekeDetailView `json:"yeeke,omitempty"` + NormalizedSYBSpec string `json:"normalizedSybSpec"` + NormalizedYeekeSpec string `json:"normalizedYeekeSpec"` + Logs []models.ReturnMatchLog `json:"logs"` +} + +func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) { + var match models.ReturnMatch + if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil { + return MatchDetail{}, err + } + detail := MatchDetail{Match: match} + + var syb models.SYBProduct + if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil { + stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID}) + var stage purchase.ProcessStageResult + if stageErr == nil { + stage = stages[syb.ID] + } + detail.SYB = &SYBDetailView{ + SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID, + ShopName: syb.ShopName, ProductTitle: syb.ProductTitle, + TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity, + ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label, + } + detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize))) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return detail, err + } + + var item models.YeekeReturnItem + if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil { + var pkg models.YeekeReturnPackage + 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) { + return detail, err + } + + var logs []models.ReturnMatchLog + if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil { + return detail, err + } + detail.Logs = logs + return detail, nil +} + +var errStateConflict = errors.New("return match state conflict") diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go new file mode 100644 index 0000000..00de4ca --- /dev/null +++ b/server/app/goauto/returnmatch/service_test.go @@ -0,0 +1,460 @@ +package returnmatch + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func testDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err = migrations.Migrate(db); err != nil { + t.Fatal(err) + } + return db +} + +// shopeeLinkForItem memoizes one shopee_product row per (db, item id) test +// case so multiple SYB rows for the same order/item share the link, same as +// real data, while still landing on the PDD-unlinked stage (which +// participates in matching per issue #338 rule 1) without needing the full +// PDD/price fixture the purchase package's own tests use. +func shopeeLinkForItem(t *testing.T, db *gorm.DB, itemID string) uint64 { + t.Helper() + var existing models.ShopeeProduct + if err := db.Where("shopee_item_id = ?", itemID).First(&existing).Error; err == nil { + return existing.ID + } + sp := models.ShopeeProduct{ShopeeItemID: itemID, Title: "蝦皮商品", ShopName: "测试店", SpecsJSON: `[]`, Currency: "CNY"} + if err := db.Create(&sp).Error; err != nil { + t.Fatal(err) + } + return sp.ID +} + +func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color, size string, createdAt time.Time) models.SYBProduct { + t.Helper() + shopeeID := shopeeLinkForItem(t, db, "100") + syb := models.SYBProduct{ + OrderCode: orderCode, DetailID: detailID, StockID: detailID, ShopeeItemID: "100", ShopeeProductID: &shopeeID, + TargetColor: color, TargetSize: size, Quantity: 1, UnitPriceCent: 1000, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`, + } + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("created_at", createdAt).Error; err != nil { + t.Fatal(err) + } + syb.CreatedAt = createdAt + return syb +} + +func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem { + t.Helper() + pkg := models.YeekeReturnPackage{ + ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1", + DestroyDeadLine: deadline, LastSyncedAt: time.Now(), + } + if err := db.Create(&pkg).Error; err != nil { + t.Fatal(err) + } + item := models.YeekeReturnItem{ + PackageID: pkg.ID, ExternalKey: "key-" + variationName + fmt.Sprint(time.Now().UnixNano()), + ItemID: "100", VariationName: variationName, LastSyncedAt: time.Now(), + } + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + return item +} + +func TestBatchMatch_EndToEnd(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)) + 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 || len(resp.Items) != 1 || !resp.Items[0].Matched { + t.Fatalf("expected one match: %+v", resp) + } + + var match models.ReturnMatch + if err := db.First(&match, resp.Items[0].MatchID).Error; err != nil { + t.Fatal(err) + } + if match.Status != models.ReturnMatchStatusMatched || match.ActiveSYBProductID == nil || *match.ActiveSYBProductID != syb.ID { + t.Fatalf("unexpected match row: %+v", match) + } +} + +func TestBatchMatch_ExpiredDeadlineNotMatched(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) } + + past := time.Date(2026, 9, 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)) + seedReturn(t, db, "白色,L", &past) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.SkippedCount != 1 { + t.Fatalf("expected skip due to expired deadline: %+v", resp) + } +} + +func TestBatchMatch_MultiColourSameOrderCrossPairing(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) + + white := seedSYB(t, db, "SYB-1", 1, "白色", "2XL", time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)) + purple := seedSYB(t, db, "SYB-1", 2, "紫色", "M", time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC)) + whiteReturn := seedReturn(t, db, "白色,2XL【建議65-75公斤】", &deadline) + purpleReturn := seedReturn(t, db, "紫色,M【建議43-53公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{white.ID, purple.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 2 { + t.Fatalf("expected both to match: %+v", resp) + } + var matches []models.ReturnMatch + if err := db.Find(&matches).Error; err != nil { + t.Fatal(err) + } + got := map[uint64]uint64{} + for _, m := range matches { + got[m.SYBProductID] = m.YeekeReturnItemID + } + if got[white.ID] != whiteReturn.ID { + t.Fatalf("white SYB product should pair with white return, got %+v", got) + } + if got[purple.ID] != purpleReturn.ID { + t.Fatalf("purple SYB product should pair with purple return, got %+v", got) + } +} + +func TestBatchMatch_ManualActionStageDoesNotParticipate(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) + + // A parse-failed SYB product computes to 待人工处理, which the confirmed + // rule says must NOT participate even though a candidate return exists. + syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 1000, ParseStatus: models.SYBParseStatusFailed, RawJSON: `{}`} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + seedReturn(t, db, "白色,L", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 0 || resp.Items[0].ReasonCode != ReasonStageIneligible { + t.Fatalf("expected 待人工处理 to be excluded from matching: %+v", resp) + } +} + +func TestConfirmThenCancel_RestoresAvailability(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)) + seedReturn(t, db, "白色,L", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup match failed: %v %+v", err, resp) + } + matchID := resp.Items[0].MatchID + + if _, err := s.Confirm(context.Background(), matchID, "op1"); err != nil { + t.Fatal(err) + } + var confirmed models.ReturnMatch + db.First(&confirmed, matchID) + if confirmed.Status != models.ReturnMatchStatusConfirmed { + t.Fatalf("expected confirmed: %+v", confirmed) + } + + if _, err := s.Cancel(context.Background(), matchID, "op2"); err != nil { + t.Fatal(err) + } + var cancelled models.ReturnMatch + db.First(&cancelled, matchID) + if cancelled.Status != models.ReturnMatchStatusCancelled || cancelled.ActiveSYBProductID != nil || cancelled.ActiveYeekeReturnItemID != nil { + t.Fatalf("expected cancel to clear active columns: %+v", cancelled) + } + + // Rematch must be able to pick the same pair again (issue #338 rule 7). + resp2, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if resp2.MatchedCount != 1 { + t.Fatalf("expected rematch after cancel to succeed: %+v", resp2) + } +} + +// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338 +// review fix-2 regression: a purchase task gets created for the SYB product +// (moving its stage to task_created, which does not participate) AFTER +// BatchMatch's outer screening pass already judged it eligible but BEFORE +// matchOneWithLock's own re-check runs. The row must be skipped with +// stage_ineligible, not matched. +func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(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", &deadline) + + // Simulate the exact race the review flagged: BatchMatch's outer + // screening pass already computed `outcome` while the product was still + // participating (pdd_unlinked). Before matchOneWithLock's own re-check + // runs (which takes the same row lock purchase.create takes and + // recomputes the stage inside that transaction), something else moves + // the product out of a participating stage — here simulated directly by + // flipping it to a parse-failed/manual_action state, same effect as a + // purchase task having been created for it in the meantime. + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil { + t.Fatal(err) + } + outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"} + + _, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester") + if !errors.Is(err, errStageNoLongerEligible) { + t.Fatalf("expected errStageNoLongerEligible, got %v", err) + } + var count int64 + db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count) + if count != 0 { + t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count) + } +} + +func TestBatchMatch_WritesLogRowInSameTransaction(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)) + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup match failed: %v %+v", err, resp) + } + var logs []models.ReturnMatchLog + if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" { + t.Fatalf("expected one matched log row: %+v", logs) + } +} + +func TestConfirmCancelRemark_EachWritesOwnLogRow(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)) + seedReturn(t, db, "白色,L", &deadline) + resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"}) + matchID := resp.Items[0].MatchID + + if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil { + t.Fatal(err) + } + if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil { + t.Fatal(err) + } + if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil { + t.Fatal(err) + } + + var logs []models.ReturnMatchLog + if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil { + t.Fatal(err) + } + wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled} + if len(logs) != len(wantActions) { + t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs) + } + for i, action := range wantActions { + if logs[i].Action != action { + t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs) + } + } + if logs[1].Detail != "看起来没问题" { + t.Fatalf("remark log must carry the remark text: %+v", logs[1]) + } +} + +func TestRemark_RejectsOverLongInputInsteadOfTruncating(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)) + seedReturn(t, db, "白色,L", &deadline) + resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + matchID := resp.Items[0].MatchID + + tooLong := strings.Repeat("字", 501) + if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) { + t.Fatalf("expected errRemarkTooLong, got %v", err) + } + var match models.ReturnMatch + db.First(&match, matchID) + if match.Remark != "" { + t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark) + } + + exactly500 := strings.Repeat("字", 500) + if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil { + t.Fatalf("exactly 500 runes must be accepted: %v", err) + } +} + +func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(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: "matcher"}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup failed: %v %+v", err, resp) + } + matchID := resp.Items[0].MatchID + if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil { + t.Fatal(err) + } + + detail, err := s.Detail(context.Background(), matchID) + if err != nil { + t.Fatal(err) + } + if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" { + t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB) + } + if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" { + t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke) + } + if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) { + t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke) + } + if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec { + t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec) + } + if len(detail.Logs) != 2 { + t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs) + } + if detail.Logs[0].Action != models.ReturnMatchLogActionRemark { + t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs) + } + if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" { + t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match) + } +} + +// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique +// constraint directly: two goroutines racing to insert an active match for +// the exact same SYB product must have exactly one winner, and the loser +// must not error out the whole call (acceptance item 9). +func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) { + db := testDB(t) + sybID := uint64(1) + returnAID := uint64(101) + returnBID := uint64(102) + + var wg sync.WaitGroup + results := make([]error, 2) + for i, retID := range []uint64{returnAID, returnBID} { + wg.Add(1) + go func(i int, retID uint64) { + defer wg.Done() + match := models.ReturnMatch{ + SYBProductID: sybID, YeekeReturnItemID: retID, + ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &retID, + Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(), + } + results[i] = db.Create(&match).Error + }(i, retID) + } + wg.Wait() + + successCount := 0 + conflictCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } else if isUniqueConstraintErr(err) { + conflictCount++ + } else { + t.Fatalf("unexpected error: %v", err) + } + } + if successCount != 1 || conflictCount != 1 { + t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results) + } +} + +func TestList_EnrichesWithYeekeReturnDisplayFields(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)) + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}}) + if err != nil || resp.MatchedCount != 1 { + t.Fatalf("setup failed: %v %+v", err, resp) + } + + items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}}) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil { + t.Fatalf("expected enriched list item: %+v", items) + } +} diff --git a/server/app/goauto/yeeke/handler.go b/server/app/goauto/yeeke/handler.go index 1417aa0..e419b5a 100644 --- a/server/app/goauto/yeeke/handler.go +++ b/server/app/goauto/yeeke/handler.go @@ -43,26 +43,29 @@ func (h Handler) db(c *gin.Context) (*gorm.DB, bool) { // only the summary fields already computed by the sync run itself; it never // carries yeeke_session (token/cookies) or a raw page response. type SyncRunDTO struct { - ID uint64 `json:"id"` - Status string `json:"status"` - Trigger string `json:"trigger"` - TotalPages int `json:"totalPages"` - ReadCount int `json:"readCount"` - CreatedCount int `json:"createdCount"` - UpdatedCount int `json:"updatedCount"` - SkippedCount int `json:"skippedCount"` - FailedCount int `json:"failedCount"` - ErrorMessage string `json:"errorMessage"` - StartedAt string `json:"startedAt"` - FinishedAt *string `json:"finishedAt"` - LastSuccessAt *string `json:"lastSuccessAt"` + ID uint64 `json:"id"` + Status string `json:"status"` + Trigger string `json:"trigger"` + TotalPages int `json:"totalPages"` + ReadCount int `json:"readCount"` + CreatedCount int `json:"createdCount"` + UpdatedCount int `json:"updatedCount"` + SkippedCount int `json:"skippedCount"` + FailedCount int `json:"failedCount"` + MissingMarkedCount int `json:"missingMarkedCount"` + RecoveredCount int `json:"recoveredCount"` + ErrorMessage string `json:"errorMessage"` + StartedAt string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` + LastSuccessAt *string `json:"lastSuccessAt"` } func toDTO(r models.YeekeSyncRun) SyncRunDTO { dto := SyncRunDTO{ ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages, ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount, - SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage, + SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, + MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage, StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), } if r.FinishedAt != nil { diff --git a/server/app/goauto/yeeke/list.go b/server/app/goauto/yeeke/list.go index 08f559e..1499cce 100644 --- a/server/app/goauto/yeeke/list.go +++ b/server/app/goauto/yeeke/list.go @@ -9,6 +9,7 @@ import ( "time" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchase" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -19,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 @@ -49,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"` } @@ -76,8 +80,29 @@ type ReturnItemRowDTO struct { DestroyDeadLine *string `json:"destroyDeadLine"` 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"` + MatchStatus string `json:"matchStatus"` + OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"` + OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"` + OccupyingSYBStage string `json:"occupyingSybStage,omitempty"` + OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"` } +const ( + ReturnMatchFilterUnmatched = "unmatched" + ReturnMatchFilterMatched = "matched" + ReturnMatchFilterConfirmed = "confirmed" +) + func formatTimePtr(t *time.Time) *string { if t == nil { return nil @@ -94,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), } } @@ -101,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), } } @@ -126,6 +153,20 @@ type itemRow struct { ItemImage *string 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"` + OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"` + OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"` } func toItemRowDTO(r itemRow) ReturnItemRowDTO { @@ -166,6 +207,35 @@ 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 + if r.MatchStatus != nil { + switch *r.MatchStatus { + case models.ReturnMatchStatusConfirmed: + dto.MatchStatus = ReturnMatchFilterConfirmed + default: + dto.MatchStatus = ReturnMatchFilterMatched + } + } + if r.OccupyingSYBProductID != nil { + dto.OccupyingSYBProductID = *r.OccupyingSYBProductID + } + if r.OccupyingSYBOrderCode != nil { + dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode + } + } return dto } @@ -190,7 +260,12 @@ func (h Handler) ListReturnPackages(c *gin.Context) { } base := db.Table("yeeke_return_package p"). - Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id") + Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id"). + // #338: single bounded LEFT JOIN onto the active match (if any) and + // the SYB product it occupies, so 匹配状态 filtering/column never + // needs a per-row lookup. + Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id"). + Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_id") if v := strings.TrimSpace(c.Query("orderSn")); v != "" { base = base.Where("p.order_sn LIKE ?", "%"+v+"%") } @@ -235,6 +310,19 @@ func (h Handler) ListReturnPackages(c *gin.Context) { if v := strings.TrimSpace(c.Query("variationId")); v != "" { base = base.Where("i.variation_id = ?", v) } + switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus { + case "": + // no filter + case ReturnMatchFilterUnmatched: + base = base.Where("m.id IS NULL") + case ReturnMatchFilterMatched: + base = base.Where("m.status = ?", models.ReturnMatchStatusMatched) + case ReturnMatchFilterConfirmed: + base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed) + default: + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"}) + return + } var total int64 if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { @@ -247,7 +335,10 @@ func (h Handler) ListReturnPackages(c *gin.Context) { "p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " + "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.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) if err := q.Find(&rows).Error; err != nil { @@ -255,9 +346,37 @@ func (h Handler) ListReturnPackages(c *gin.Context) { return } + // #338: one bounded ProcessStages call for the distinct occupying SYB + // product ids on this page (never per-row), so the 「被占用 SYB 订单 + // 商品」 column can also show its current stage. + sybIDs := make([]uint64, 0, len(rows)) + seenSYB := make(map[uint64]bool, len(rows)) + for _, r := range rows { + if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] { + seenSYB[*r.OccupyingSYBProductID] = true + sybIDs = append(sybIDs, *r.OccupyingSYBProductID) + } + } + var stages map[uint64]purchase.ProcessStageResult + if len(sybIDs) > 0 { + var stageErr error + stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs) + if stageErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + } + items := make([]ReturnItemRowDTO, 0, len(rows)) for _, r := range rows { - items = append(items, toItemRowDTO(r)) + dto := toItemRowDTO(r) + if r.OccupyingSYBProductID != nil { + if stage, ok := stages[*r.OccupyingSYBProductID]; ok { + dto.OccupyingSYBStage = stage.Stage + dto.OccupyingSYBStageLabel = stage.Label + } + } + items = append(items, dto) } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ diff --git a/server/app/goauto/yeeke/list_match_test.go b/server/app/goauto/yeeke/list_match_test.go new file mode 100644 index 0000000..1be7eee --- /dev/null +++ b/server/app/goauto/yeeke/list_match_test.go @@ -0,0 +1,113 @@ +package yeeke + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" +) + +// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI +// support test: the yeeke returns list must be able to filter by match +// status and show which SYB order product occupies a matched return, using +// the single bounded LEFT JOIN added to ListReturnPackages (no N+1). +func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testDB(t) + now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) + deadline := now.Add(30 * 24 * time.Hour) + + pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now} + if err := db.Create(&pkg).Error; err != nil { + t.Fatal(err) + } + matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now} + unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now} + if err := db.Create(&matchedItem).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&unmatchedItem).Error; err != nil { + t.Fatal(err) + } + + syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + match := models.ReturnMatch{ + SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID, + ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID, + Status: models.ReturnMatchStatusMatched, MatchedAt: now, + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + handler := Handler{DB: db} + engine := gin.New() + engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages) + do := func(req *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + return w + } + + // No filter: both rows present, matched row carries occupying info. + w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil)) + var resp itemRowListResp + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 2 { + t.Fatalf("expected 2 rows, got %+v", resp.Data) + } + byKey := map[string]ReturnItemRowDTO{} + for _, item := range resp.Data.Items { + byKey[item.Key] = item + } + matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)] + if matchedDTO.MatchStatus != ReturnMatchFilterMatched { + t.Fatalf("expected matched status: %+v", matchedDTO) + } + if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID { + t.Fatalf("expected occupying SYB info: %+v", matchedDTO) + } + if matchedDTO.OccupyingSYBStageLabel == "" { + t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO) + } + unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)] + if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 { + t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO) + } + + // matchStatus=unmatched filters down to just the unmatched row. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil)) + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) { + t.Fatalf("expected only the unmatched row: %+v", resp.Data) + } + + // matchStatus=matched filters down to just the matched row. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil)) + decodeJSON(t, w.Body.Bytes(), &resp) + if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) { + t.Fatalf("expected only the matched row: %+v", resp.Data) + } + + // Invalid value is rejected, not silently ignored. + w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil)) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code) + } +} + +func decodeJSON(t *testing.T, body []byte, v any) { + t.Helper() + if err := json.Unmarshal(body, v); err != nil { + t.Fatalf("decode response: %v (%s)", err, body) + } +} diff --git a/server/app/goauto/yeeke/sync.go b/server/app/goauto/yeeke/sync.go index 7d419ca..ef68630 100644 --- a/server/app/goauto/yeeke/sync.go +++ b/server/app/goauto/yeeke/sync.go @@ -40,6 +40,17 @@ 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. Persisted + // as yeeke_sync_run.missing_marked_count; when the safety valve skips + // marking it stays 0 and the reason goes to error_message. + MissingMarked int + // Recovered is the number of yeeke_return_item rows that were "missing" + // before this run and reappeared in it, flipping back to "ok" (#338). + // Persisted as yeeke_sync_run.recovered_count. + Recovered int } // knownClaimStatuses lists the status values the sync code currently @@ -177,7 +188,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro var runErr error defer func() { now := time.Now().UTC() - updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} + updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now} if rep.Status == "succeeded" { updates["last_success_at"] = now } @@ -185,6 +196,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 +229,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) @@ -220,7 +239,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro } seen[finger] = true for _, x := range p.Records { - created, updated, err := s.upsert(ctx, x) + created, updated, recovered, err := s.upsert(ctx, x) if err != nil { rep.Failed++ if firstWriteErr == nil { @@ -229,6 +248,7 @@ func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, erro continue } rep.Read++ + rep.Recovered += recovered if created { rep.Created++ } else if updated { @@ -238,9 +258,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 +275,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 @@ -273,7 +359,10 @@ func pageFingerprint(p yeekeclient.ReturnPage) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } -func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) { + +// upsert writes one package and its items. recovered counts items that were +// marked "missing" before and reappeared in this run (#338). +func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err error) { now := time.Now().UTC() key := packageKey(p) status := external(p.Status) @@ -282,7 +371,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool // behind without its items. Rows are always inserted fully populated — // MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an // empty placeholder insert would carry, which failed every record. - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row models.YeekeReturnPackage e := tx.Where("external_id = ?", key).First(&row).Error isNew = errors.Is(e, gorm.ErrRecordNotFound) @@ -304,7 +393,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 +419,10 @@ 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"} + if existing.SyncStatus == "missing" { + recovered++ + } + iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok", "missing_since": (*time.Time)(nil)} if e = tx.Model(&existing).Updates(iv).Error; e != nil { return e } @@ -336,7 +431,7 @@ func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool return nil }) if err != nil { - return false, false, err + return false, false, 0, err } - return isNew, !isNew, nil + return isNew, !isNew, recovered, nil } diff --git a/server/app/goauto/yeeke/sync_missing_test.go b/server/app/goauto/yeeke/sync_missing_test.go new file mode 100644 index 0000000..0e80fc8 --- /dev/null +++ b/server/app/goauto/yeeke/sync_missing_test.go @@ -0,0 +1,406 @@ +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) + rep3, err := s.Sync(context.Background(), "manual") + if err != nil { + t.Fatalf("run 3 (recovery): %v", err) + } + if rep3.Recovered != 1 || rep3.MissingMarked != 0 { + t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked) + } + // Both counters are persisted on the run rows shown by the sync-runs page. + var run2, run3 models.YeekeSyncRun + if e := db.First(&run2, rep2.RunID).Error; e != nil { + t.Fatal(e) + } + if e := db.First(&run3, rep3.RunID).Error; e != nil { + t.Fatal(e) + } + if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 { + t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount) + } + + // A fresh variable is used here (not the p1 declared above): GORM's Scan + // does not reset an already non-nil pointer field to nil when the new + // 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/1789800700000_return_match.go b/server/cmd/migrate/migration/version-local/1789800700000_return_match.go new file mode 100644 index 0000000..3a092f4 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800700000_return_match.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: creates return_match and return_match_log (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatch) +} +func migrateReturnMatch(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/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go new file mode 100644 index 0000000..91f3824 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: creates return_match_batch (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch) +} +func migrateReturnMatchBatch(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/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/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go new file mode 100644 index 0000000..c0e8a43 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801000000_yeeke_sync_run_missing_counts.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts) +} +func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go new file mode 100644 index 0000000..6b6d253 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801200000_after_sales_return_match.go @@ -0,0 +1,76 @@ +package version_local + +import ( + "fmt" + "runtime" + + "go-admin/app/goauto/access" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" +) + +// #341 follow-up: the after-sales role was created before #338 added the +// return-match API surface. Add only the reviewed return-match grants so the +// existing role can use the new SYB action without recreating or changing its +// users. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch) +} + +type afterSalesReturnMatchAPI struct { + ID int `gorm:"column:id;primaryKey;autoIncrement"` + Title string `gorm:"column:title;size:128"` + Path string `gorm:"column:path;size:128"` + Type string `gorm:"column:type;size:16"` + Action string `gorm:"column:action;size:16"` +} + +func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" } + +type afterSalesReturnMatchPolicy struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement"` + Ptype string `gorm:"column:ptype;size:100"` + V0 string `gorm:"column:v0;size:100"` + V1 string `gorm:"column:v1;size:100"` + V2 string `gorm:"column:v2;size:100"` + V3 string `gorm:"column:v3;size:100"` + V4 string `gorm:"column:v4;size:100"` + V5 string `gorm:"column:v5;size:100"` +} + +func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" } + +func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + var role struct { + RoleID int `gorm:"column:role_id"` + } + if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil { + return fmt.Errorf("find after-sales role: %w", err) + } + for _, permission := range access.AdminAPIs { + if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" { + continue + } + api := afterSalesReturnMatchAPI{} + if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method). + Assign(afterSalesReturnMatchAPI{Title: permission.Title, Type: "BUS"}). + FirstOrCreate(&api).Error; err != nil { + return fmt.Errorf("ensure return-match API: %w", err) + } + policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method} + var count int64 + if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := tx.Create(&policy).Error; err != nil { + return err + } + } + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/api/goauto/return-matches.js b/web/src/api/goauto/return-matches.js new file mode 100644 index 0000000..792b9dc --- /dev/null +++ b/web/src/api/goauto/return-matches.js @@ -0,0 +1,25 @@ +import request from '@/utils/request' + +export function listReturnMatches(params, options = {}) { + return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options }) +} + +export function getReturnMatch(matchId, options = {}) { + return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options }) +} + +export function batchMatchReturns(data) { + return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data }) +} + +export function confirmReturnMatch(matchId) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' }) +} + +export function cancelReturnMatch(matchId) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' }) +} + +export function remarkReturnMatch(matchId, data) { + return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data }) +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 454997d..4230ca5 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,7 +12,7 @@ - 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }} + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} @@ -51,6 +51,28 @@
{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}
+ + +
@@ -162,6 +184,82 @@ + + +
+ 成功匹配 {{ returnMatchBatchResult.matchedCount }} 条 + 无候选 {{ returnMatchBatchResult.noCandidateCount }} 条 + 阶段不参与跳过 {{ returnMatchBatchResult.stageIneligibleCount }} 条 + 并发冲突跳过 {{ returnMatchBatchResult.conflictCount }} 条 +
+ + + + + + + +
+ + + +
+ +
+ +
+
@@ -227,6 +325,7 @@ import { listDevices } from '@/api/goauto/devices' import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks' import { listCollectionRules } from '@/api/goauto/collection-rules' +import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches' import { createRequestId } from '@/utils/request-id' import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference' import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue' @@ -255,6 +354,11 @@ export default { collectionBatchData: { ruleId: null, deviceId: null }, collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, + returnMatchByProductId: {}, + returnMatchLoading: false, + returnMatchBatchLoading: false, + returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 }, + matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' }, detail: { open: false, loading: false, item: null }, correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' }, shopeeDetail: { open: false, productId: null, targetColor: '', action: '' }, @@ -265,7 +369,8 @@ export default { purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) }, purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length }, canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') }, - processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] }, + processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] }, + returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) }, aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) }, aiMatchButtonReason() { if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' @@ -313,6 +418,7 @@ export default { this.total = r.data.total this.loading = false void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) + void this.loadReturnMatches(this.products.map(item => item.id), generation) } catch (error) { if (generation !== this.loadGeneration) return if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { @@ -357,13 +463,19 @@ export default { // 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品 // 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。 isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) }, + // #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。 + isReturnMatchCandidate(row) { + const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready']) + return participating.has(this.purchaseReady(row).processStage) + }, + isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' }, // `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates - // / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。 - isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) }, + // / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。 + isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) }, rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) }, purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } }, purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, - processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } }, + processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } }, purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) { if (generation !== this.loadGeneration) return @@ -428,6 +540,109 @@ export default { }, formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' }, + // ---------------- 退货匹配 (#338) ---------------- + formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' }, + // #338: an already-matched return whose destroy deadline has passed is only flagged, never auto-cancelled. + isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() }, + matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status }, + async loadReturnMatches(ids, generation = this.loadGeneration) { + this.returnMatchByProductId = {} + if (!ids.length) return + this.returnMatchLoading = true + try { + const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true }) + if (generation !== this.loadGeneration) return + const map = {} + for (const item of (r.data?.items || [])) { + if (item.status === 'cancelled') continue + if (!map[item.sybProductId]) map[item.sybProductId] = item + } + this.returnMatchByProductId = map + } catch { + // 展示失败不影响主列表;用户可重新查询重试。 + } finally { + if (generation === this.loadGeneration) this.returnMatchLoading = false + } + }, + matchReasonMeta(code) { + return { + stage_ineligible: '阶段不参与', + no_candidate: '无候选', + conflict: '并发冲突' + }[code] || code + }, + async runBatchMatchReturns() { + const ids = this.returnMatchCandidateIds + if (!ids.length || this.returnMatchBatchLoading) return + this.returnMatchBatchLoading = true + try { + const r = await batchMatchReturns({ sybProductIds: ids }) + const items = r.data.items || [] + this.returnMatchBatchResult = { + open: true, items, + matchedCount: r.data.matchedCount || 0, + noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length, + stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length, + conflictCount: items.filter(i => i.reasonCode === 'conflict').length + } + await this.load() + } finally { + this.returnMatchBatchLoading = false + } + }, + async openMatchDetail(matchId, focusRemark = false) { + this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' } + try { + const r = await getReturnMatch(matchId) + this.matchDetail.data = r.data + this.matchDetail.remarkText = r.data.match.remark || '' + } finally { + this.matchDetail.loading = false + } + if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus()) + }, + async refreshMatchDetail() { + const r = await getReturnMatch(this.matchDetail.id) + this.matchDetail.data = r.data + this.matchDetail.remarkText = r.data.match.remark || '' + await this.load() + }, + async saveMatchRemark() { + this.matchDetail.saving = true + try { + await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText }) + ElMessage.success('已保存备注') + await this.refreshMatchDetail() + } finally { + this.matchDetail.saving = false + } + }, + async confirmMatchDetail() { + try { + await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' }) + } catch { return } + await confirmReturnMatch(this.matchDetail.id) + ElMessage.success('已确认匹配') + await this.refreshMatchDetail() + }, + async cancelMatchDetail() { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(this.matchDetail.id) + ElMessage.success('已取消匹配') + this.matchDetail.open = false + await this.load() + }, + async quickCancelMatch(matchId) { + try { + await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' }) + } catch { return } + await cancelReturnMatch(matchId) + ElMessage.success('已取消匹配') + await this.load() + }, + // ---------------- 批量创建采购任务 ---------------- async openSinglePurchase(row) { this.$refs.productTable?.clearSelection() @@ -625,9 +840,11 @@ export default { .missing{color:#b91c1c} .quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0} .thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px} +.return-match-cell{display:flex;gap:8px;align-items:flex-start} .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 5edf6d8..714a9c9 100644 --- a/web/src/views/goauto/yeeke-returns/index.vue +++ b/web/src/views/goauto/yeeke-returns/index.vue @@ -22,6 +22,11 @@ + + + + + 查询重置 @@ -61,9 +66,28 @@ {{ claimStatusMeta(row.claimStatus).label }} + + + + + + + + + @@ -139,8 +163,15 @@ export default { loading: false, loadError: '', items: [], total: 0, createTimeRange: null, summary: { lastSuccessAt: '', lastRun: null }, summaryError: '', shopOptions: [], - query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' }, + query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }, detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] }, + // #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里 + // 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。 + matchStatusOptions: [ + { label: '未匹配', value: 'unmatched' }, + { label: '退货待确认', value: 'matched' }, + { label: '已用退货', value: 'confirmed' } + ], // yeeke's only confirmed claim-status value is the raw string "1" // (HAR evidence, #336 requirement doc). Any other value the sync sees // is preserved verbatim and flagged status_unrecognized — never @@ -159,8 +190,12 @@ export default { // unmapped value we have not seen yet — show it as-is rather than a // bare digit or a guessed Chinese label. claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } }, + matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } }, + openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) }, statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } }, formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) }, + // #338: a matched return past its destroy deadline is flagged only, never auto-released. + isDeadlinePassed(value) { return Boolean(value) && new Date(value).getTime() <= Date.now() }, async load() { this.loading = true; this.loadError = '' try { @@ -202,7 +237,7 @@ export default { }, reset() { this.createTimeRange = null - this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '' } + this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' } this.load() }, async openDetail(row) { @@ -245,4 +280,5 @@ export default { .item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px} .muted{font-size:12px;color:#909399} @media(max-width:800px){.page-heading{flex-direction:column}} +.deadline-passed{color:var(--el-color-danger);font-size:12px} diff --git a/web/src/views/goauto/yeeke-sync-runs/index.vue b/web/src/views/goauto/yeeke-sync-runs/index.vue index 4eedeb7..0c23a25 100644 --- a/web/src/views/goauto/yeeke-sync-runs/index.vue +++ b/web/src/views/goauto/yeeke-sync-runs/index.vue @@ -41,6 +41,8 @@ + +