fix(purchase): block new purchase tasks for actively return-matched SYB products (#338)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user