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>
+11 -1
View File
@@ -374,7 +374,12 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
6. 已选择分配的客户端;
7. 所选客户端存在且在当前账号可见范围内;其在线状态和 `purchase_mode` 只影响何时领取,不阻止提前指派;
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务;
9. 同一顺运宝明细没有 `succeeded` 的采购任务;成功采购是终态,禁止重复采购;
10. 同一顺运宝明细没有 `manual_review` 的采购任务;该状态可能已经下单,必须先人工核对,禁止重新采购。
`failed` 和 `cancelled` 继续沿用现有人工判断后重新创建策略。本规则必须在服务端校验,
不能只依赖顺运宝列表的复选框和按钮状态。
Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向采购员提供模式选择:
@@ -421,6 +426,11 @@ Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向
| `failed` | 失败 | 客户端报告失败 |
| `cancelled` | 已取消 | 人工取消 |
顺运宝页面根据关联采购任务实时推导采购阶段:`succeeded` 显示“采购完成”,
`manual_review` 显示“采购待人工核对”,活动任务显示“已创建采购任务”。优先级按
成功、待人工核对、活动任务、其他处理阶段排列;完成和待核对行都只能查看任务,
不能再次创建采购任务。
`[必须]` **Admin 看不到客户端执行到哪一步**(没有心跳,是有意的)。
`claimed` 之后就只能等结果。想知道细节看 Client 那边的界面。
+10 -4
View File
@@ -527,7 +527,9 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、采集中(超时)、
采集失败、规格待匹配、可采购和已创建任务。
采集失败、规格待匹配、可采购、已创建任务、采购完成和采购待人工核对。
- 成功采购任务显示“采购完成”和“查看采购结果”;`manual_review` 显示“采购待人工核对”
和“查看采购任务”,并明确提示可能已经下单、必须先核对。两种状态都不允许勾选或重新采购。
- `collecting` 已超过 15 分钟,或已找不到 `pending/assigned/claimed` 采集任务时,
显示“PDD 采集中(超时)”和“重新创建采集任务”,不能继续伪装成正常进行中。
- 每行显示一个有文字的“下一步”按钮。商品标题是服务端渲染处理弹窗的明确入口,
@@ -605,7 +607,9 @@ grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留
`[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么:
- 同一批中合法行可以创建,失败行逐条显示明细 ID 和原因;数据库错误则整批回滚。
- 同一明细已有 `pending`、`assigned` 或 `claimed` 的采购任务时拒绝重复创建。
- 同一明细已有 `pending`、`assigned`、`claimed`、`succeeded` 或 `manual_review` 的采购任务时
拒绝重复创建;成功任务是采购终态,待人工核对任务可能已经下单。`failed/cancelled` 的现有
人工判断后重新创建策略不变。
- 顺运宝列表继续显示台币售价,但绝不把它带入人民币价格上限。
```text
@@ -620,8 +624,10 @@ grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留
可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细,
不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。客户端选项显示名称和 Client
ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选中。没有可见 Client 时禁用
提交并说明需要管理员先绑定客户端。已有未
结束任务的行显示“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新
提交并说明需要管理员先绑定客户端。已有未结束任务的行显示“查看采购任务”并进入
“采集采购”页,不得再次创建;采购完成行显示
“查看采购结果”,待人工核对行显示“查看采购任务”,两者同样进入按订单号筛选的采集采购页。
服务端仍必须重新
校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、创建审计和重复任务,不能
信任列表页的旧状态。