From b527204204a68ff6664fffee33aab868c0bdb815 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 14:59:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=A1=BA=E8=BF=90?= =?UTF-8?q?=E5=AE=9D=E6=89=B9=E9=87=8F=E5=88=9B=E5=BB=BA=E9=87=87=E9=9B=86?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 21 ++++++ admin/handler/web/others_test.go | 23 +++++++ admin/handler/web/web.go | 1 + admin/main_test.go | 31 +++++++++ admin/service/shopee_pdd.go | 28 ++++++++ admin/service/shopee_pdd_test.go | 92 ++++++++++++++++++++++++++ admin/service/syb.go | 11 +++ admin/service/syb_workflow_test.go | 41 ++++++++++++ admin/static/js/app.js | 24 ++++--- admin/syb_interaction_template_test.go | 16 +++++ admin/templates/syb/list.html | 52 ++++++++++++++- docs/admin/05-ui-specification.md | 15 ++++- 12 files changed, 341 insertions(+), 14 deletions(-) diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 10b3ea8..2012fa6 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -611,6 +611,27 @@ func (h *Handler) SybCollectPdd(c *gin.Context) { h.sybRedirect(c, service.FormatCollectTaskMessage(result)) } +// SybCollectPddBatch 为勾选的顺运宝明细批量创建 PDD 采集任务。 +// Service 会重新校验阶段、按 PDD 商品去重并核对指定客户端的可见范围。 +func (h *Handler) SybCollectPddBatch(c *gin.Context) { + ids := c.PostFormArray("ids") + if len(ids) == 0 { + h.sybRedirect(c, "没有选择可采集的顺运宝明细,没有创建任务。") + return + } + result, err := service.CreateSybPddCollectTasksForUser( + h.db, currentUser(c), ids, c.PostForm("client_id")) + if err != nil { + message := "批量采集任务未创建:系统没有改动这一批数据,请稍后重试。" + if service.IsValidationError(err) { + message = "批量采集任务未创建:" + err.Error() + } + h.sybRedirect(c, message) + return + } + h.sybRedirect(c, service.FormatCollectTaskMessage(result)) +} + // SybMatch 保存规格匹配结果。 // // 关键:保存的是**可复用的商品规格映射**(写 spec_mappings), diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index ac2a3f8..da48df4 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -166,3 +166,26 @@ func TestPurchaseTaskOptionsFromForm_忽略篡改模式并固定Live(t *testing. t.Fatalf("采购客户端读取错误: %+v", options) } } + +func TestSybCollectPddBatch_未选择时保留列表筛选(t *testing.T) { + context, recorder := sybPostContext(t, url.Values{ + "order_no": {"ORDER-1"}, "shop": {"qwg8fkb044"}, + "stage": {"pdd_pending"}, "page": {"2"}, + }) + (&Handler{}).SybCollectPddBatch(context) + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + query := location.Query() + for name, want := range map[string]string{ + "order_no": "ORDER-1", "shop": "qwg8fkb044", "stage": "pdd_pending", "page": "2", + } { + if got := query.Get(name); got != want { + t.Errorf("批量采集返回丢失 %s:got=%q want=%q Location=%s", name, got, want, location.String()) + } + } + if !strings.Contains(query.Get("msg"), "没有选择") { + t.Fatalf("未选择应返回可读提示:%s", location.String()) + } +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 1255f75..1639dab 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -79,6 +79,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/syb/login-and-sync", h.SybLoginAndSync) pages.POST("/syb/associate-pdd", h.SybAssociatePdd) pages.POST("/syb/collect-pdd", h.SybCollectPdd) + pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch) pages.POST("/syb/match", h.SybMatch) pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/delete", h.SybDelete) diff --git a/admin/main_test.go b/admin/main_test.go index 817831e..bba59e4 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -65,6 +65,37 @@ func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) { } } +func TestSybBulkActions_按服务端动作能力隔离勾选项(t *testing.T) { + files := map[string][]string{ + "templates/syb/list.html": { + `data-need-checked="collect"`, `data-need-checked="purchase"`, + `data-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}"`, + `{{if .CanCollect}}form="syb-collect-form"{{end}}`, + `action="/syb/collect-pdd-batch"`, `id="syb-collect-modal"`, + }, + "static/js/app.js": { + `supportsSelectAction(box, action)`, + `checkedCount(action)`, + `supportsSelectAction(box, "purchase")`, + `getAttribute("data-collect-action")`, + }, + "handler/web/web.go": { + `pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch)`, + }, + } + for path, wants := range files { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, want := range wants { + if !strings.Contains(string(content), want) { + t.Errorf("%s 缺少动作隔离约束 %q", path, want) + } + } + } +} + func TestPddDetailRecollect_使用独立入口并可选客户端(t *testing.T) { content, err := os.ReadFile("templates/pdd/edit_modal.html") if err != nil { diff --git a/admin/service/shopee_pdd.go b/admin/service/shopee_pdd.go index b33a8eb..6f7669f 100644 --- a/admin/service/shopee_pdd.go +++ b/admin/service/shopee_pdd.go @@ -157,3 +157,31 @@ func CreateSybPddCollectTaskForUser(db *sql.DB, actor *model.User, sybID string) } return CreateShopeePddCollectTaskForUser(db, actor, context.Order.ShopeeGoodsID) } + +// CreateSybPddCollectTasksForUser 把勾选的顺运宝明细转换成 PDD 商品后批量建采集任务。 +// 同一 PDD 商品可能对应多条货运单明细,因此先按 goods_id 去重;任何明细不存在或 +// 已不处于可采集阶段时整批拒绝,避免浏览器过期或参数篡改造成部分创建。 +func CreateSybPddCollectTasksForUser(db *sql.DB, actor *model.User, sybIDs []string, clientID string) (CollectTaskResult, error) { + sybIDs = dedupe(sybIDs) + if len(sybIDs) == 0 { + return CollectTaskResult{}, invalidInput("没有选择可采集的顺运宝明细") + } + + goodsIDs := make([]string, 0, len(sybIDs)) + for _, sybID := range sybIDs { + context, err := repository.GetSybOrderContext(db, sybID) + if err != nil { + return CollectTaskResult{}, err + } + if context == nil { + return CollectTaskResult{}, invalidInput("顺运宝明细 %s 不存在,请刷新页面后重新选择", sybID) + } + stage, _, _, _ := sybStageFor(*context) + if stage != SybStagePddPending && stage != SybStagePddFailed { + return CollectTaskResult{}, invalidInput("顺运宝明细 %s 已不处于 PDD 待采集或采集失败状态,请刷新页面后重新选择", sybID) + } + goodsIDs = append(goodsIDs, context.PddGoodsID) + } + + return CreatePddCollectTasksForUser(db, actor, dedupe(goodsIDs), clientID) +} diff --git a/admin/service/shopee_pdd_test.go b/admin/service/shopee_pdd_test.go index 28990bf..c736c00 100644 --- a/admin/service/shopee_pdd_test.go +++ b/admin/service/shopee_pdd_test.go @@ -1,9 +1,12 @@ package service import ( + "database/sql" "errors" "testing" + "time" + "cmautobuy/admin/model" "cmautobuy/admin/repository" ) @@ -116,3 +119,92 @@ func TestCreateShopeePddCollectTaskForUser_记录当前用户(t *testing.T) { t.Fatalf("蝦皮入口任务创建人=%q err=%v", creator, err) } } + +func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) { + db := newTestDB(t) + admin, _, _ := insertTaskUsers(t, db) + seedShopeeProduct(t, db, "1001", "商品一") + seedShopeeProduct(t, db, "1002", "商品二") + if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil { + t.Fatal(err) + } + if _, err := AssociateShopeePdd(db, "1002", pddURLA, false); err != nil { + t.Fatal(err) + } + seedSybCollectOrder(t, db, "SYB-1", "1001") + seedSybCollectOrder(t, db, "SYB-2", "1002") + + result, err := CreateSybPddCollectTasksForUser( + db, admin, []string{"SYB-1", "SYB-2", "SYB-1"}, "") + if err != nil || result.Created != 1 { + t.Fatalf("批量创建结果=%+v err=%v", result, err) + } + var taskCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 { + t.Fatalf("同一 PDD 商品应只建一条任务,count=%d err=%v", taskCount, err) + } +} + +func TestCreateSybPddCollectTasksForUser_不可采集明细整批拒绝(t *testing.T) { + db := newTestDB(t) + admin, _, _ := insertTaskUsers(t, db) + seedShopeeProduct(t, db, "1001", "商品一") + seedShopeeProduct(t, db, "1002", "商品二") + AssociateShopeePdd(db, "1001", pddURLA, false) + AssociateShopeePdd(db, "1002", pddURLB, false) + seedSybCollectOrder(t, db, "SYB-PENDING", "1001") + seedSybCollectOrder(t, db, "SYB-COLLECTED", "1002") + if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collected' WHERE goods_id='937122477375'`); err != nil { + t.Fatal(err) + } + + _, err := CreateSybPddCollectTasksForUser( + db, admin, []string{"SYB-PENDING", "SYB-COLLECTED"}, "") + if !IsValidationError(err) { + t.Fatalf("不可采集明细应返回校验错误,实际 %v", err) + } + var taskCount int + db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount) + if taskCount != 0 { + t.Fatalf("整批拒绝后不应产生部分任务,实际 %d", taskCount) + } + pending, _ := repository.GetPddProductByGoodsID(db, "737116531267") + if pending.CollectStatus != model.CollectPending { + t.Fatalf("整批拒绝后待采集商品状态被改变为 %s", pending.CollectStatus) + } +} + +func TestCreateSybPddCollectTasksForUser_拒绝不可见客户端(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + if err := RegisterClient(db, model.Client{ClientID: "client-b", Name: "B 的机器"}, true); err != nil { + t.Fatal(err) + } + if _, _, err := AssignClient(db, admin, "client-b", buyerB.UserID, + time.Date(2026, 8, 11, 1, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + seedShopeeProduct(t, db, "1001", "商品一") + AssociateShopeePdd(db, "1001", pddURLA, false) + seedSybCollectOrder(t, db, "SYB-1", "1001") + + _, err := CreateSybPddCollectTasksForUser(db, buyerA, []string{"SYB-1"}, "client-b") + if !IsValidationError(err) { + t.Fatalf("不可见客户端应返回校验错误,实际 %v", err) + } + var taskCount int + db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount) + if taskCount != 0 { + t.Fatalf("客户端权限校验失败后不应建任务,实际 %d", taskCount) + } +} + +func seedSybCollectOrder(t *testing.T, db *sql.DB, sybID, shopeeGoodsID string) { + t.Helper() + if _, err := repository.UpsertSybOrder(db, model.SybOrder{ + SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品", ProductSpec: "黑色,M", + ShopeeGoodsID: shopeeGoodsID, Quantity: 1, SybData: `{}`, + }); err != nil { + t.Fatalf("插入顺运宝测试明细失败: %v", err) + } +} diff --git a/admin/service/syb.go b/admin/service/syb.go index 19cfad6..b4ed88e 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -939,7 +939,9 @@ type SybOrderView struct { StageHelp string ActionText string NeedsAttention bool + CanCollect bool CanPurchase bool + SelectionHint string DefaultMaxPrice string MappedPddChoice string MappingOptionKey string @@ -1129,7 +1131,16 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { Quantity: o.Quantity, ImageURL: o.ImageURL, UpdatedAt: formatLocalTime(o.UpdatedAt)} v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context) + v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed v.CanPurchase = v.Stage == SybStagePurchaseReady + switch { + case v.CanCollect: + v.SelectionHint = "可批量创建 PDD 采集任务" + case v.CanPurchase: + v.SelectionHint = "可批量创建采购任务" + default: + v.SelectionHint = "当前阶段不能批量操作:" + v.StageHelp + } if v.CanPurchase { v.MappingOptionKey = context.MappingOptionKey v.ContextVersion = mappingContextVersion(context) diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index 344a5a0..6d013f1 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -130,3 +130,44 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { } } } + +func TestSybOrderViewFor_批量动作能力按阶段隔离(t *testing.T) { + base := repository.SybOrderContext{ + Order: model.SybOrder{SybID: "SYB-1", SpecKey: "黑色,M", Quantity: 1}, + PddGoodsID: "737116531267", + } + cases := []struct { + status model.CollectStatus + mappingKey string + wantCollect, wantPurchase bool + }{ + {status: model.CollectPending, wantCollect: true}, + {status: model.CollectFailed, wantCollect: true}, + {status: model.CollectCollecting}, + {status: model.CollectCollected}, + } + for _, tc := range cases { + context := base + context.PddCollectStatus = string(tc.status) + context.MappingOptionKey = tc.mappingKey + view := sybOrderViewFor(context) + if view.CanCollect != tc.wantCollect || view.CanPurchase != tc.wantPurchase { + t.Errorf("status=%s: CanCollect=%v CanPurchase=%v", tc.status, view.CanCollect, view.CanPurchase) + } + if view.SelectionHint == "" { + t.Errorf("status=%s: 应给出选择能力说明", tc.status) + } + } + key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"}) + if err != nil { + t.Fatal(err) + } + ready := base + ready.PddCollectStatus = string(model.CollectCollected) + ready.PddSkusJSON = collectedThreeDimensions + ready.MappingOptionKey = key + view := sybOrderViewFor(ready) + if view.CanCollect || !view.CanPurchase { + t.Fatalf("采购就绪行应只允许采购:CanCollect=%v CanPurchase=%v", view.CanCollect, view.CanPurchase) + } +} diff --git a/admin/static/js/app.js b/admin/static/js/app.js index aadfa27..ba5cbc2 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -40,18 +40,25 @@ } /* ── 选中数量 ────────────────────────────── */ - function checkedCount() { - return document.querySelectorAll( - "tbody input[type=checkbox]:checked:not(:disabled)" + function supportsSelectAction(box, action) { + if (!action) return true; + var actions = (box.getAttribute("data-select-action") || "").split(/\s+/); + return actions.indexOf(action) !== -1; + } + + function checkedCount(action) { + return Array.prototype.filter.call( + document.querySelectorAll("tbody input[type=checkbox]:checked:not(:disabled)"), + function (box) { return supportsSelectAction(box, action); } ).length; } /* ── 需要勾选才能点的按钮 ────────────────── 标了 data-need-checked 的按钮,没勾选任何行时禁用。 */ function syncButtons() { - var n = checkedCount(); document.querySelectorAll("[data-need-checked]").forEach(function (btn) { - btn.disabled = n === 0; + var action = btn.getAttribute("data-need-checked") || ""; + btn.disabled = checkedCount(action) === 0; }); } @@ -148,8 +155,8 @@ if (singleID) { selected[singleID] = true; } else { - document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) { - selected[box.value] = true; + document.querySelectorAll("tbody input[name=ids]:checked:not(:disabled)").forEach(function (box) { + if (supportsSelectAction(box, "purchase")) selected[box.value] = true; }); } var shown = 0; @@ -183,11 +190,12 @@ if (!form || !modal) return; var count = modal.querySelector("[data-collect-count]"); var submitButton = modal.querySelector("[data-collect-submit]"); + var action = openButton.getAttribute("data-collect-action") || ""; if (!count || !submitButton) return; openButton.addEventListener("click", function () { var fixedCount = openButton.getAttribute("data-collect-count-value"); - count.textContent = fixedCount === null ? checkedCount() : fixedCount; + count.textContent = fixedCount === null ? checkedCount(action) : fixedCount; }); form.addEventListener("submit", function (event) { if (event.submitter !== submitButton) return; diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index ef7509c..2fcd821 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -30,6 +30,13 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { SybID: "SYB-PDD", OrderNo: "ORDER-PDD", Title: "待关联商品", Stage: service.SybStagePddMissing, StageText: "未关联 PDD", StageHelp: "下一步关联采购商品", ActionText: "关联 PDD", + SelectionHint: "当前阶段不能批量操作:下一步关联采购商品", + }, + { + SybID: "SYB-COLLECT", OrderNo: "ORDER-COLLECT", Title: "待采集商品", + Stage: service.SybStagePddPending, StageText: "PDD 待采集", + StageHelp: "尚未创建采集任务", ActionText: "创建采集任务", + CanCollect: true, SelectionHint: "可批量创建 PDD 采集任务", }, } var output bytes.Buffer @@ -50,6 +57,15 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { `href="/tasks?type=purchase`, `q=ORDER-TASK"`, `data-detail-open-id="SYB-PDD"`, + `action="/syb/collect-pdd-batch"`, + `data-need-checked="collect"`, + `data-need-checked="purchase"`, + `data-collect-action="collect"`, + `data-select-action="collect"`, + `form="syb-collect-form"`, + `id="syb-collect-modal"`, + `同一 PDD 商品关联多条货运单时只创建一条任务`, + `title="当前阶段不能批量操作:下一步关联采购商品"`, } { if !strings.Contains(body, want) { t.Errorf("顺运宝列表缺少 %q", want) diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 8a81a8f..49deedf 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -22,7 +22,18 @@ - + + + + + + + + +
@@ -109,8 +120,13 @@ {{range .Rows}} - + + + {{.SybID}} {{.OrderNo}} @@ -163,6 +179,36 @@ + +