From dec99cb81d511890ea0d3040753c52bb2e4d9b29 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 11:49:10 +0800 Subject: [PATCH] fix(purchase): block new purchase tasks for actively return-matched SYB products (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rejectIfActiveReturnMatch (app/goauto/purchase/return_match_block.go) is called from Service.create right after the SYB row is locked: a SYB product with an active (matched or confirmed) return_match row is rejected with CodeReturnMatched. Since BatchCreate already calls Service.Create per row and treats a create error as a per-row skip, this single insertion point covers both single and batch creation — batch creation skips only the blocked rows and reports CodeReturnMatched, it does not fail the whole batch. A query error here is treated as internal() and never silently allows creation. Process stage computation (process_stage.go) gains two new stages, ProcessStageReturnPending (退货待确认) and ProcessStageReturnUsed (已用退货), sourced from a new bounded dataset.activeReturnMatchBySYB query in loadBatchPreviewDataset (batch.go); with zero return_match rows this query returns nothing and every other stage branch is unchanged (updated the batch preview bounded-query-count assertion in batch_test.go from 8 to 9 to reflect the new, still-bounded query). Regression coverage (return_match_block_test.go): single create rejected for matched and for confirmed match, cancelled match does not block, batch create creates the clean row and skips only the matched row with CodeReturnMatched, and a zero-return-match baseline still succeeds unchanged (acceptance item 11). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/purchase/batch.go | 18 +++ server/app/goauto/purchase/batch_test.go | 9 +- server/app/goauto/purchase/process_stage.go | 21 ++- .../app/goauto/purchase/return_match_block.go | 30 ++++ .../purchase/return_match_block_test.go | 145 ++++++++++++++++++ server/app/goauto/purchase/service.go | 3 + server/app/goauto/purchase/types.go | 7 +- 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 server/app/goauto/purchase/return_match_block.go create mode 100644 server/app/goauto/purchase/return_match_block_test.go diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 0ff0243..67f631d 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -142,6 +142,11 @@ type batchPreviewDataset struct { // 见 #289 与 sybimport.RawSpecHalves。 collapsedColorByShopee map[uint64]map[string]bool collapsedSizeByShopee map[uint64]map[string]bool + // activeReturnMatchBySYB is #338's return-match stage input: a non-zero + // entry means the SYB product currently has an active (matched or + // confirmed) return_match row, which forces the 退货待确认/已用退货 + // stages regardless of what the rest of the pipeline would compute. + activeReturnMatchBySYB map[uint64]models.ReturnMatch } // loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries. @@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba skuCombinationsByPDD: make(map[uint64][]pddSKUCombination), collapsedColorByShopee: make(map[uint64]map[string]bool), collapsedSizeByShopee: make(map[uint64]map[string]bool), + activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba dataset.latestTaskBySYB[*task.SYBProductID] = task } } + // #338: load active return matches for these SYB products. This query + // only ever ADDS a stage override on top of the pre-#338 computation; an + // empty result set (the common case for products never matched) leaves + // every other branch of processStageFromDataset byte-for-byte unchanged + // (issue #338 acceptance item 11 regression). + var activeMatches []models.ReturnMatch + if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil { + return dataset, err + } + for _, m := range activeMatches { + dataset.activeReturnMatchBySYB[m.SYBProductID] = m + } return dataset, nil } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 3be3c96..64e65ad 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } // #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测), - // 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字; - // 只有新增的查询确实有界时才允许上调。 - if queries != 8 { - t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries) + // 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃 + // 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体 + // 数字;只有新增的查询确实有界时才允许上调。 + if queries != 9 { + t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", queries) } if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 { t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index ca70dc0..bcd668f 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -21,7 +21,13 @@ const ( ProcessStageTaskCreated = "task_created" ProcessStagePurchaseSucceeded = "purchase_succeeded" ProcessStageOrderReview = "order_review" - processActionOpenPDDLink = "open_pdd_link" + // ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages + // added by issue #338: a SYB product with an active return_match blocks + // purchase creation regardless of what stage it would otherwise compute + // to (see rejectIfActiveReturnMatch and processStageFromDataset below). + ProcessStageReturnPending = "return_pending" + ProcessStageReturnUsed = "return_used" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -35,6 +41,8 @@ var processStageLabels = map[string]string{ ProcessStageTaskCreated: "已创建任务", ProcessStagePurchaseSucceeded: "采购成功", ProcessStageOrderReview: "待人工核对", + ProcessStageReturnPending: "退货待确认", + ProcessStageReturnUsed: "已用退货", } type ProcessStageResult struct { @@ -132,6 +140,17 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat } } + // #338: an active return match overrides the normal computation below — + // it only ever fires for a SYB product that has one, so with zero + // matches (the pre-#338 default) this branch is a no-op and every stage + // below is unchanged (acceptance item 11 regression). + if match, ok := dataset.activeReturnMatchBySYB[id]; ok { + if match.Status == models.ReturnMatchStatusConfirmed { + return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match") + } + return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match") + } + syb, ok := dataset.sybByID[id] if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go new file mode 100644 index 0000000..812375a --- /dev/null +++ b/server/app/goauto/purchase/return_match_block.go @@ -0,0 +1,30 @@ +package purchase + +import ( + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// rejectIfActiveReturnMatch implements issue #338's purchase-creation block: +// a SYB product with an active (matched or confirmed) return_match row must +// not get a NEW purchase task created for it (single or batch — BatchCreate +// calls s.Create per row, so this single check point covers both paths and +// batch creation simply skips the row via the normal createErr handling, +// issue #338 acceptance item 5). Resuming/reparsing an existing task is a +// different code path and is not affected. +// +// On any query error this returns the error (never nil) so a DB failure can +// never silently allow a purchase for an already-matched product (issue +// #338 acceptance item 11). +func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error { + var match models.ReturnMatch + err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error + if err == nil { + return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务") + } + if err == gorm.ErrRecordNotFound { + return nil + } + return internal(err) +} diff --git a/server/app/goauto/purchase/return_match_block_test.go b/server/app/goauto/purchase/return_match_block_test.go new file mode 100644 index 0000000..5ea3e70 --- /dev/null +++ b/server/app/goauto/purchase/return_match_block_test.go @@ -0,0 +1,145 @@ +package purchase + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #338 acceptance item 5/6/11: a SYB product with an active return match +// must be rejected for NEW single-task creation, batch creation must skip +// only that row (not fail the whole batch), and with zero matches (the +// default) behavior must be byte-for-byte unchanged. + +func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection, got success") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection for confirmed match too") + } + if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched, got %v", err) + } +} + +func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("cancelled match must not block creation: %v", err) + } +} + +// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5 +// "batch creation must skip just those rows" regression: one blocked SYB +// product and one clean one in the same batch call. +func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f1 := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f1.pdd.ID) + + // second, independent eligible SYB product sharing the same shopee/pdd + // chain shape but its own row. + syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := db.Create(&syb2).Error; err != nil { + t.Fatal(err) + } + + match := models.ReturnMatch{ + SYBProductID: f1.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{ + RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID, + }) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if resp.CreatedCount != 1 || resp.FailedCount != 1 { + t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items) + } + for _, item := range resp.Items { + if item.SYBProductID == f1.syb.ID { + if item.Created { + t.Fatalf("matched row must not be created: %+v", item) + } + if item.ReasonCode != CodeReturnMatched { + t.Fatalf("expected CodeReturnMatched reason, got %+v", item) + } + } + if item.SYBProductID == syb2.ID && !item.Created { + t.Fatalf("clean row must still be created: %+v", item) + } + } +} + +// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11 +// regression: with zero return_match rows anywhere, creation behaves exactly +// as it did before #338. +func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if _, err := createLive(t, s, f); err != nil { + t.Fatalf("zero-match baseline must succeed unchanged: %v", err) + } +} + +func asServiceError(err error) (*ServiceError, bool) { + se, ok := err.(*ServiceError) + return se, ok +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 7b4ffa9..fb25908 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -178,6 +178,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil { return notFound(err, "顺云宝商品不存在") } + if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil { + return err + } if syb.ShopeeProductID == nil { return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 2a5f780..332e71e 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -36,7 +36,12 @@ const ( CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING" CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID" CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING" - CodeInternal = "INTERNAL_ERROR" + // CodeReturnMatched is returned when a SYB product has an active (matched + // or confirmed) return match: issue #338 blocks creating NEW purchase + // tasks for such a product until the match is cancelled. Resuming or + // reparsing an existing task is not affected. + CodeReturnMatched = "PURCHASE_RETURN_MATCHED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct {