fix: 修复顺运宝采购完成状态与重复采购 (#169)

This commit is contained in:
chengma
2026-08-11 18:05:27 +08:00
parent c61a028d9c
commit 5528b4b874
9 changed files with 160 additions and 26 deletions
+27 -14
View File
@@ -396,18 +396,20 @@ const sybOrderContextFrom = `
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
// 处理阶段由 service 计算,Repository 只提供数据库事实。
type SybOrderContext struct {
Order model.SybOrder
ShopeeExists bool
PddGoodsID string
PddGoodsURL string
PddCollectStatus string
PddCollectMsg string
PddSkusJSON string
PddUpdatedAt string
MappingOptionKey string
MappingOptions string
HasActiveTask bool
HasActiveCollectTask bool
Order model.SybOrder
ShopeeExists bool
PddGoodsID string
PddGoodsURL string
PddCollectStatus string
PddCollectMsg string
PddSkusJSON string
PddUpdatedAt string
MappingOptionKey string
MappingOptions string
HasSucceededPurchaseTask bool
HasManualReviewPurchaseTask bool
HasActiveTask bool
HasActiveCollectTask bool
}
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
@@ -417,13 +419,14 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var shopeeExists int
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString
var mappingKey, mappingOptions sql.NullString
var hasActiveTask, hasActiveCollectTask int
var hasSucceededTask, hasManualReviewTask, hasActiveTask, hasActiveCollectTask int
err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
&mappingKey, &mappingOptions, &hasActiveTask, &hasActiveCollectTask,
&mappingKey, &mappingOptions, &hasSucceededTask, &hasManualReviewTask,
&hasActiveTask, &hasActiveCollectTask,
)
c.Order.ShopName = shopName.String
c.Order.Title = title.String
@@ -441,6 +444,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
c.PddUpdatedAt = pddUpdatedAt.String
c.MappingOptionKey = mappingKey.String
c.MappingOptions = mappingOptions.String
c.HasSucceededPurchaseTask = hasSucceededTask != 0
c.HasManualReviewPurchaseTask = hasManualReviewTask != 0
c.HasActiveTask = hasActiveTask != 0
c.HasActiveCollectTask = hasActiveCollectTask != 0
return c, err
@@ -456,6 +461,10 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.status = 'manual_review'),
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed')),
@@ -493,6 +502,10 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options,
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.status = 'succeeded'),
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.status = 'manual_review'),
EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed')),
+6
View File
@@ -268,6 +268,12 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
if context == nil {
return "顺运宝明细不存在", nil, "", nil
}
if context.HasSucceededPurchaseTask {
return "该顺运宝明细已有成功采购任务,禁止重复采购", context, "", nil
}
if context.HasManualReviewPurchaseTask {
return "该顺运宝明细有待人工核对的采购任务,请先核对订单,禁止重复采购", context, "", nil
}
if context.Order.SpecKey == "" {
return "顺运宝未提供规格", context, "", nil
}
+58
View File
@@ -341,6 +341,64 @@ func TestCreatePurchaseTasks_安全字段和重复保护(t *testing.T) {
}
}
func TestCreatePurchaseTasks_成功或待核对任务禁止重复采购(t *testing.T) {
cases := []struct {
status model.TaskStatus
wantStage string
wantReason string
wantAttention bool
}{
{model.TaskSucceeded, SybStagePurchaseCompleted, "已有成功采购任务", false},
{model.TaskManualReview, SybStagePurchaseReview, "待人工核对", true},
}
for _, tc := range cases {
t.Run(string(tc.status), func(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-LOCKED")
if err := SaveSybMapping(db, "SYB-LOCKED", key, "USR-1"); err != nil {
t.Fatal(err)
}
admin, _, _ := prepareClientAssignmentUsers(t, db)
if err := RegisterClient(db, model.Client{ClientID: "CLIENT-1", Name: "采购机"}, true); err != nil {
t.Fatal(err)
}
created, err := CreatePurchaseTasks(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4200}}, "CLIENT-1")
if err != nil || created.Created != 1 {
t.Fatalf("准备采购任务失败: result=%+v err=%v", created, err)
}
if _, err := db.Exec(`UPDATE tasks SET status=? WHERE task_type='purchase' AND syb_id=?`, tc.status, "SYB-LOCKED"); err != nil {
t.Fatal(err)
}
before := countPurchaseTasksForSyb(t, db, "SYB-LOCKED")
result, err := CreatePurchaseTasks(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4300}}, "CLIENT-1")
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
!strings.Contains(result.Failures[0].Reason, tc.wantReason) {
t.Fatalf("%s 任务必须阻止重复采购: result=%+v err=%v", tc.status, result, err)
}
if after := countPurchaseTasksForSyb(t, db, "SYB-LOCKED"); after != before {
t.Fatalf("拒绝后任务数量从 %d 变为 %d", before, after)
}
list, err := ListSybOrdersView(db, "", "", tc.wantStage, 1)
if err != nil || list.Total != 1 || len(list.Rows) != 1 ||
list.Rows[0].Stage != tc.wantStage || list.Rows[0].NeedsAttention != tc.wantAttention {
t.Fatalf("%s 阶段筛选错误: result=%+v err=%v", tc.status, list, err)
}
})
}
}
func countPurchaseTasksForSyb(t *testing.T, db *sql.DB, sybID string) int {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='purchase' AND syb_id=?`, sybID).Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
func TestCreatePurchaseTasks_客户端权限和逐条失败(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
+16 -6
View File
@@ -959,6 +959,8 @@ const (
SybStageMappingPending = "mapping_pending"
SybStagePurchaseReady = "purchase_ready"
SybStageTaskCreated = "task_created"
SybStagePurchaseCompleted = "purchase_completed"
SybStagePurchaseReview = "purchase_review"
SybStagePurchaseBlocked = "purchase_blocked"
)
@@ -980,6 +982,8 @@ func SybStageOptions() []SybStageOption {
{Value: SybStageMappingPending, Text: "规格待匹配"},
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
{Value: SybStageTaskCreated, Text: "已创建采购任务"},
{Value: SybStagePurchaseCompleted, Text: "采购完成"},
{Value: SybStagePurchaseReview, Text: "采购待人工核对"},
{Value: SybStagePurchaseBlocked, Text: "采购数据异常"},
}
}
@@ -1004,6 +1008,12 @@ func SybStageLabel(stage string) string {
func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) {
switch {
case c.HasSucceededPurchaseTask:
return SybStagePurchaseCompleted, "采购完成", "采购任务已成功完成,请到采集采购页面查看 PDD 订单编号和下单时间。", "查看采购结果"
case c.HasManualReviewPurchaseTask:
return SybStagePurchaseReview, "采购待人工核对", "任务可能已经下单,必须先核对订单,禁止重新创建采购任务。", "查看采购任务"
case c.HasActiveTask:
return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看采购任务"
case c.Order.SpecKey == "":
return SybStageSpecMissing, "采购数据异常:顺运宝未提供规格", "缺少规格原文,不能匹配或创建采购任务。", "核对数据"
case c.PddGoodsID == "" || c.PddCollectStatus == "":
@@ -1020,8 +1030,6 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
help += " 原因:" + c.PddCollectMsg
}
return SybStagePddFailed, "PDD 采集失败", help, "重新采集"
case c.HasActiveTask:
return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看采购任务"
case !mappingIsValid(c):
return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格"
case c.Order.Quantity <= 0:
@@ -1059,7 +1067,8 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
stage = ParseSybStage(stage)
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, keyword, shop, stage, page)
}
filter := repository.SybOrderFilter{Keyword: keyword, Shop: shop, Stage: stage}
@@ -1096,8 +1105,8 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
return result, nil
}
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
// 这四个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选实时推导阶段。
// 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
@@ -1166,7 +1175,8 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
}
}
}
v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated
v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated &&
v.Stage != SybStagePurchaseCompleted
if o.PriceTwdCent > 0 {
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
} else {
+19
View File
@@ -125,6 +125,14 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
{SybStagePddFailed, "重新采集", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
{SybStageMappingPending, "匹配规格", copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
{SybStageTaskCreated, "查看采购任务", copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
{SybStagePurchaseCompleted, "查看采购结果", copyWith(func(c *repository.SybOrderContext) {
c.HasSucceededPurchaseTask, c.HasManualReviewPurchaseTask = true, true
c.Order.SpecKey = ""
})},
{SybStagePurchaseReview, "查看采购任务", copyWith(func(c *repository.SybOrderContext) {
c.HasManualReviewPurchaseTask = true
c.MappingOptionKey = ""
})},
{SybStagePurchaseBlocked, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })},
{SybStagePurchaseReady, "创建采购任务", ready},
}
@@ -182,6 +190,17 @@ func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) {
if view.CanCollect || !view.CanPurchase {
t.Fatalf("采购就绪行应只允许采购:CanCollect=%v CanPurchase=%v", view.CanCollect, view.CanPurchase)
}
for _, change := range []func(*repository.SybOrderContext){
func(c *repository.SybOrderContext) { c.HasSucceededPurchaseTask = true },
func(c *repository.SybOrderContext) { c.HasManualReviewPurchaseTask = true },
} {
locked := ready
change(&locked)
lockedView := sybOrderViewFor(locked)
if lockedView.CanCollect || lockedView.CanPurchase {
t.Fatalf("采购完成或待核对行不得再提供批量动作:%+v", lockedView)
}
}
}
func TestSybCollectingIsStale_有效任务但时间损坏时允许恢复(t *testing.T) {
+12
View File
@@ -26,6 +26,16 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
Stage: service.SybStageTaskCreated, StageText: "已创建采购任务",
StageHelp: "已有任务", ActionText: "查看采购任务",
},
{
SybID: "SYB-DONE", OrderNo: "ORDER-DONE", Title: "采购完成商品",
Stage: service.SybStagePurchaseCompleted, StageText: "采购完成",
StageHelp: "采购任务已成功完成", ActionText: "查看采购结果",
},
{
SybID: "SYB-REVIEW", OrderNo: "ORDER-REVIEW", Title: "待核对商品",
Stage: service.SybStagePurchaseReview, StageText: "采购待人工核对",
StageHelp: "禁止重新采购", ActionText: "查看采购任务",
},
{
SybID: "SYB-PDD", OrderNo: "ORDER-PDD", Title: "待关联商品",
Stage: service.SybStagePddMissing, StageText: "未关联 PDD",
@@ -56,6 +66,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
`data-purchase-id="SYB-READY"`,
`href="/tasks?type=purchase`,
`q=ORDER-TASK"`,
`q=ORDER-DONE"`,
`q=ORDER-REVIEW"`,
`data-detail-open-id="SYB-PDD"`,
`action="/syb/collect-pdd-batch"`,
`data-need-checked="collect"`,
+1 -1
View File
@@ -154,7 +154,7 @@
{{if eq .Stage "purchase_ready"}}
<button type="button" data-modal-open="purchase-modal" data-purchase-open
data-purchase-id="{{.SybID}}">{{.ActionText}}</button>
{{else if eq .Stage "task_created"}}
{{else if or (eq .Stage "task_created") (eq .Stage "purchase_completed") (eq .Stage "purchase_review")}}
<a class="button-link" href="/tasks?type=purchase&q={{urlquery .OrderNo}}">{{.ActionText}}</a>
{{else}}
<button type="button" data-detail-open-id="{{.SybID}}">{{.ActionText}}</button>