Compare commits
15
Commits
main
...
018e3566c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018e3566c0 | ||
|
|
5ac8e4c8fd | ||
|
|
ddd875d989 | ||
|
|
ff6e87649c | ||
|
|
8b4db9c89e | ||
|
|
00fb274fd4 | ||
|
|
1e944b9d2e | ||
|
|
20a8144be0 | ||
|
|
f2c8307c17 | ||
|
|
92c42ba1fe | ||
|
|
fc99be0392 | ||
|
|
dec99cb81d | ||
|
|
fbde80cc20 | ||
|
|
9bebf3930d | ||
|
|
3516fb7edc |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ func MigratedModels() []any {
|
||||
&models.PDDProductReplacement{},
|
||||
&models.PDDProductReplacementItem{},
|
||||
&models.PDDProductReplacementWorkerLease{},
|
||||
&models.ReturnMatch{},
|
||||
&models.ReturnMatchLog{},
|
||||
&models.ReturnMatchBatch{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "该商品尚未关联蝦皮商品")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 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" {
|
||||
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 errors.Is(err, ErrTooManyItems) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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, ",")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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)
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
|
||||
// size can now go up to 500/page, so the button's selection can exceed the
|
||||
// previously-unbounded batch-match request size).
|
||||
const maxBatchMatchItems = 500
|
||||
|
||||
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
|
||||
// maxBatchMatchItems SYB product ids; the handler turns this into an
|
||||
// INVALID_REQUEST response instead of a 500.
|
||||
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
|
||||
|
||||
// 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) {
|
||||
if len(req.SYBProductIDs) > maxBatchMatchItems {
|
||||
return BatchMatchResponse{}, ErrTooManyItems
|
||||
}
|
||||
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")
|
||||
@@ -0,0 +1,485 @@
|
||||
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_RejectsMoreThan500Items(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
|
||||
ids := make([]uint64, maxBatchMatchItems+1)
|
||||
for i := range ids {
|
||||
ids[i] = uint64(i + 1)
|
||||
}
|
||||
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
|
||||
if !errors.Is(err, ErrTooManyItems) {
|
||||
t.Fatalf("expected ErrTooManyItems, got %v", err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("expected empty response on rejection, got %+v", resp)
|
||||
}
|
||||
|
||||
var batchCount int64
|
||||
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batchCount != 0 {
|
||||
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 20
|
||||
}
|
||||
if request.PageSize > 100 {
|
||||
request.PageSize = 100
|
||||
if request.PageSize > 500 {
|
||||
request.PageSize = 500
|
||||
}
|
||||
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
|
||||
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
|
||||
|
||||
@@ -93,6 +93,36 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
order := realOrder()
|
||||
for i := 0; i < 3; i++ {
|
||||
detail := realDetailA()
|
||||
detail.ID += uint64(i)
|
||||
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
|
||||
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
|
||||
t.Fatalf("apply detail %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("list with oversized page size: %v", err)
|
||||
}
|
||||
if overLimit.PageSize != 500 {
|
||||
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
|
||||
}
|
||||
|
||||
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
|
||||
if err != nil {
|
||||
t.Fatalf("list at exactly 500: %v", err)
|
||||
}
|
||||
if within.PageSize != 500 {
|
||||
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := sybimport.NewService(db)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+129
-10
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in
|
||||
// migrations.MigratedModels) on databases whose earlier versions are already
|
||||
// recorded in sys_migration.
|
||||
func init() {
|
||||
_, f, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts)
|
||||
}
|
||||
func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
@@ -51,9 +51,30 @@
|
||||
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="匹配到的退货商品" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="returnMatchByProductId[row.id]">
|
||||
<div class="return-match-cell">
|
||||
<el-image v-if="returnMatchByProductId[row.id].image" :src="returnMatchByProductId[row.id].image" fit="cover" class="thumb" preview-teleported />
|
||||
<div>
|
||||
<div>{{ returnMatchByProductId[row.id].orderSn || '—' }}</div>
|
||||
<div class="muted">{{ returnMatchByProductId[row.id].variationName || '—' }}</div>
|
||||
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
|
||||
<div class="quick-link-actions">
|
||||
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
|
||||
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
|
||||
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="muted">未匹配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
@@ -162,6 +183,81 @@
|
||||
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 匹配退货批量结果 -->
|
||||
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
|
||||
<div class="purchase-summary" aria-live="polite">
|
||||
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
|
||||
<span>无候选 <strong>{{ returnMatchBatchResult.noCandidateCount }}</strong> 条</span>
|
||||
<span>阶段不参与跳过 <strong>{{ returnMatchBatchResult.stageIneligibleCount }}</strong> 条</span>
|
||||
<span v-if="returnMatchBatchResult.conflictCount" class="danger-text">并发冲突跳过 <strong>{{ returnMatchBatchResult.conflictCount }}</strong> 条</span>
|
||||
</div>
|
||||
<el-table :data="returnMatchBatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
|
||||
<el-table-column label="SYB 商品 ID" prop="sybProductId" width="120" />
|
||||
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'warning'">{{ row.matched ? '已匹配' : '跳过' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="原因" min-width="260"><template #default="{ row }">{{ row.matched ? '—' : (row.reason || matchReasonMeta(row.reasonCode)) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="row.matched && row.matchId" type="primary" link @click="openMatchDetail(row.matchId)">查看对比</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" @click="returnMatchBatchResult.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 匹配详情对比 -->
|
||||
<el-dialog v-model="matchDetail.open" title="退货匹配详情" width="900px" :close-on-click-modal="false">
|
||||
<div v-loading="matchDetail.loading">
|
||||
<template v-if="matchDetail.data">
|
||||
<el-alert :title="'当前状态:' + matchStatusLabel(matchDetail.data.match.status)" :type="matchDetail.data.match.status === 'confirmed' ? 'success' : matchDetail.data.match.status === 'cancelled' ? 'info' : 'warning'" :closable="false" show-icon class="notice" />
|
||||
<div class="split">
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">SYB 商品</h3>
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
|
||||
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题">{{ matchDetail.data.syb.productTitle }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标颜色/尺码">{{ matchDetail.data.syb.targetColor || '—' }} / {{ matchDetail.data.syb.targetSize || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">{{ matchDetail.data.syb.quantity }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前阶段"><el-tag :type="processMeta(matchDetail.data.syb.currentStage).type">{{ matchDetail.data.syb.currentStageLabel }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedSybSpec }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-else class="muted">SYB 商品不存在或已删除</p>
|
||||
</div>
|
||||
<div class="split-col">
|
||||
<h3 class="section-title">yeeke 退货商品</h3>
|
||||
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
|
||||
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
|
||||
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ matchDetail.data.yeeke.shopName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名">{{ matchDetail.data.yeeke.itemName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格文字">{{ matchDetail.data.yeeke.variationName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">{{ matchDetail.data.yeeke.quantity }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销毁截止">{{ formatMatchDeadline(matchDetail.data.yeeke.destroyDeadline) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedYeekeSpec }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p v-else class="muted">退货商品不存在或已删除</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="section-title">备注</h3>
|
||||
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
|
||||
<div class="drawer-actions">
|
||||
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
|
||||
</div>
|
||||
<h3 class="section-title">操作日志</h3>
|
||||
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
|
||||
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatMatchDeadline(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="动作" width="90" prop="action" />
|
||||
<el-table-column label="操作人" width="100" prop="operator" />
|
||||
<el-table-column label="详情" min-width="200" prop="detail" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="matchDetail.open = false">关闭</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
|
||||
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
@@ -227,6 +323,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'
|
||||
@@ -234,6 +331,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
|
||||
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
|
||||
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
|
||||
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
|
||||
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
|
||||
const PURCHASE_BATCH_MAX = 100
|
||||
const COLLECTION_BATCH_MAX = 100
|
||||
const RETURN_MATCH_BATCH_MAX = 500
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
@@ -254,7 +357,12 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
query: { page: 1, pageSize: 100, 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,22 +373,37 @@ 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') },
|
||||
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) },
|
||||
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
|
||||
aiMatchButtonReason() {
|
||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
||||
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
|
||||
if (this.aiMatchCandidates.length) return ''
|
||||
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
||||
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
||||
},
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
|
||||
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
|
||||
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
|
||||
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
|
||||
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
|
||||
// 这里用相同口径预估,避免提交后才发现超限。
|
||||
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
|
||||
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
|
||||
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
|
||||
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
|
||||
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
|
||||
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
|
||||
imageSearchBatchDurationText() {
|
||||
@@ -313,6 +436,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) {
|
||||
@@ -326,7 +450,7 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
|
||||
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
|
||||
async ensureShopOptions() {
|
||||
if (this.shopOptionsLoaded) return
|
||||
@@ -357,24 +481,43 @@ 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] || '' },
|
||||
// #339: the page can now show up to 500 rows, but the preview endpoint
|
||||
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
|
||||
// caps a single request at 100 ids, so this splits the current page into
|
||||
// sequential chunks of <=100 and merges their results. Any chunk failing
|
||||
// falls back to the same page-wide failure state the single-request path
|
||||
// used before, so rows never end up in a partially-updated mix of real
|
||||
// and failed readiness.
|
||||
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
|
||||
if (generation !== this.loadGeneration) return
|
||||
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
|
||||
this.purchaseReadiness = {}
|
||||
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
|
||||
this.purchaseReadinessLoading = true
|
||||
const chunkSize = 100
|
||||
const merged = {}
|
||||
try {
|
||||
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
|
||||
for (let start = 0; start < ids.length; start += chunkSize) {
|
||||
const chunk = ids.slice(start, start + chunkSize)
|
||||
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
|
||||
if (generation !== this.loadGeneration) return
|
||||
for (const item of r.data.items) merged[item.sybProductId] = item
|
||||
}
|
||||
this.purchaseReadiness = merged
|
||||
await this.$nextTick()
|
||||
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
|
||||
this.$refs.productTable?.clearSelection()
|
||||
@@ -428,6 +571,107 @@ export default {
|
||||
},
|
||||
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
|
||||
|
||||
// ---------------- 退货匹配 (#338) ----------------
|
||||
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
|
||||
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()
|
||||
@@ -611,7 +855,7 @@ export default {
|
||||
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
|
||||
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
|
||||
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
|
||||
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
|
||||
.shop-input{width:130px}.order-codes-input{width:168px}.unlinked-tag{margin-left:8px}
|
||||
.shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.toolbar-actions :deep(.el-form-item__content){gap:8px}
|
||||
@@ -625,9 +869,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}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
|
||||
<el-form-item label="匹配状态">
|
||||
<el-select v-model="query.matchStatus" clearable placeholder="全部" style="width:130px">
|
||||
<el-option v-for="item in matchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -61,9 +66,27 @@
|
||||
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="yeeke 可用性" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.syncStatus === 'missing'" type="danger" effect="plain">不可用(yeeke 列表中已不存在)</el-tag>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
|
||||
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
|
||||
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
|
||||
<el-table-column label="匹配状态" min-width="100">
|
||||
<template #default="{ row }"><el-tag :type="matchStatusMeta(row.matchStatus).type" effect="plain">{{ matchStatusMeta(row.matchStatus).label }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="被占用 SYB 订单商品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.occupyingSybProductId">
|
||||
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
|
||||
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
@@ -139,8 +162,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,6 +189,8 @@ 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 }) },
|
||||
async load() {
|
||||
@@ -202,7 +234,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) {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
<el-table-column label="更新" prop="updatedCount" width="70" />
|
||||
<el-table-column label="跳过" prop="skippedCount" width="70" />
|
||||
<el-table-column label="失败" prop="failedCount" width="70" />
|
||||
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
|
||||
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
|
||||
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user