diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 38027d1..dc09cf8 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -75,6 +75,12 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") return } + assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取可选客户端失败,数据没有被改动。刷新页面重试。") + return + } status := msg if status == "" { @@ -99,21 +105,22 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, } c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ - "Keyword": keyword, - "ShopNameKeyword": filter.ShopName, - "StatusFilter": filter.Status, - "StatusOptions": service.ShopeeStatusOptions(), - "ShopFilter": filter.Shop, - "ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"), - "ImageFilter": filter.Image, - "ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"), - "Rows": result.Rows, - "Status": status, - "HasAnyProducts": result.HasAnyProducts, - "IsFiltered": result.IsFiltered, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), - "CurrentPage": result.Page, - "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), + "Keyword": keyword, + "ShopNameKeyword": filter.ShopName, + "StatusFilter": filter.Status, + "StatusOptions": service.ShopeeStatusOptions(), + "ShopFilter": filter.Shop, + "ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"), + "ImageFilter": filter.Image, + "ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"), + "Rows": result.Rows, + "Status": status, + "HasAnyProducts": result.HasAnyProducts, + "IsFiltered": result.IsFiltered, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "CurrentPage": result.Page, + "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), + "AssignableClients": assignableClients, })) } @@ -180,6 +187,28 @@ func (h *Handler) ShopeeCollect(c *gin.Context) { h.shopeeRedirect(c, service.FormatCollectTaskMessage(result)) } +// ShopeeCollectBatch 为勾选的蝦皮商品按当前 PDD 关联批量创建采集任务。 +func (h *Handler) ShopeeCollectBatch(c *gin.Context) { + ids := c.PostFormArray("ids") + if len(ids) == 0 { + h.shopeeRedirect(c, "没有勾选任何蝦皮商品,没有创建任务") + return + } + result, err := service.CreateShopeePddCollectTasksForUser( + h.db, currentUser(c), ids, c.PostForm("client_id")) + if err != nil { + status := http.StatusInternalServerError + message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。" + if service.IsValidationError(err) { + status = http.StatusBadRequest + message = err.Error() + "。这一批任务整体没有创建,请重新选择。" + } + fail(c, status, message) + return + } + h.shopeeRedirect(c, service.FormatCollectTaskMessage(result)) +} + func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { params := url.Values{} if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" { diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 1639dab..99c510d 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -59,6 +59,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/shopee/save", h.ShopeeSave) pages.POST("/shopee/delete", h.ShopeeDelete) pages.POST("/shopee/collect", h.ShopeeCollect) + pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch) // 2. PDD 商品 pages.GET("/pdd", h.PddList) diff --git a/admin/main_test.go b/admin/main_test.go index 9c32bfb..35a2a2f 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -234,6 +234,24 @@ func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) { } } +func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) { + content, err := os.ReadFile("templates/shopee/list.html") + if err != nil { + t.Fatal(err) + } + page := string(content) + for _, want := range []string{ + `action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`, + `data-collect-form="shopee-collect-form"`, `form="shopee-collect-form"`, + ``, + `不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`, + } { + if !strings.Contains(page, want) { + t.Errorf("蝦皮批量采集入口缺少 %q", want) + } + } +} + func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) { content, err := os.ReadFile("templates/shopee/detail_modal.html") if err != nil { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 62fdd3b..3302854 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -301,6 +301,37 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, return &p, nil } +// ListShopeePddLinksByGoodsIDs 批量读取蝦皮商品当前关联的 PDD 商品 ID。 +// 返回结果只包含仍存在的蝦皮商品;值为空表示商品存在但尚未关联 PDD。 +func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]string, error) { + links := make(map[string]string, len(goodsIDs)) + if len(goodsIDs) == 0 { + return links, nil + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",") + args := make([]any, len(goodsIDs)) + for i, goodsID := range goodsIDs { + args[i] = goodsID + } + rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '') + FROM shopee_products WHERE goods_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err) + } + defer rows.Close() + for rows.Next() { + var shopeeGoodsID, pddGoodsID string + if err := rows.Scan(&shopeeGoodsID, &pddGoodsID); err != nil { + return nil, fmt.Errorf("读取蝦皮商品 PDD 关联失败: %w", err) + } + links[shopeeGoodsID] = pddGoodsID + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历蝦皮商品 PDD 关联失败: %w", err) + } + return links, nil +} + // UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。 // 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。 func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error { diff --git a/admin/service/pdd.go b/admin/service/pdd.go index 5fc1b7c..80e6737 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -543,9 +543,11 @@ func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) { type CollectTaskResult struct { Created int - SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交 - SkippedCollected int // 已经采集完成,本来就不需要重新采集 - SkippedDeleted int // 商品不存在或已被软删除 + SkippedShopeeMissing int // 浏览器提交的蝦皮商品已不存在 + SkippedUnlinked int // 蝦皮商品尚未关联 PDD + SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交 + SkippedCollected int // 已经采集完成,本来就不需要重新采集 + SkippedDeleted int // 商品不存在或已被软删除 // RetryWaitText 是 SkippedCollecting 里最快能重试的还需等待时长, // 形如 "12 分钟";SkippedCollecting 为 0 时是空串。 @@ -555,7 +557,7 @@ type CollectTaskResult struct { // Skipped 是跳过总数,供测试和粗粒度统计用; // 界面提示要按原因分开说,见 FormatCollectTaskMessage,不要直接显示这个数。 func (r CollectTaskResult) Skipped() int { - return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted + return r.SkippedShopeeMissing + r.SkippedUnlinked + r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted } // CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。 @@ -784,6 +786,12 @@ func formatRetryWait(remain time.Duration) string { // (比如还要等多久),见 #24。 func FormatCollectTaskMessage(r CollectTaskResult) string { var reasons []string + if r.SkippedShopeeMissing > 0 { + reasons = append(reasons, fmt.Sprintf("%d 个蝦皮商品已不存在", r.SkippedShopeeMissing)) + } + if r.SkippedUnlinked > 0 { + reasons = append(reasons, fmt.Sprintf("%d 个未关联 PDD", r.SkippedUnlinked)) + } if r.SkippedCollecting > 0 { reason := fmt.Sprintf("%d 个正在采集中", r.SkippedCollecting) if r.Created > 0 { diff --git a/admin/service/shopee_pdd.go b/admin/service/shopee_pdd.go index 0f7dfa1..2880a29 100644 --- a/admin/service/shopee_pdd.go +++ b/admin/service/shopee_pdd.go @@ -112,6 +112,43 @@ func CreateShopeePddCollectTaskForUser(db *sql.DB, actor *model.User, shopeeGood return CreatePddCollectTasksForUser(db, actor, []string{product.PddGoodsID}, "") } +// CreateShopeePddCollectTasksForUser 把勾选的蝦皮商品转换成当前 PDD 商品后批量建采集任务。 +// 蝦皮与 PDD 可以多对一,因此交给统一 PDD 服务前先按 PDD goods_id 去重; +// 商品不存在和未关联按原因跳过,其余状态、防重和客户端权限仍由统一服务处理。 +func CreateShopeePddCollectTasksForUser(db *sql.DB, actor *model.User, shopeeGoodsIDs []string, clientID string) (CollectTaskResult, error) { + shopeeGoodsIDs = dedupe(shopeeGoodsIDs) + if len(shopeeGoodsIDs) == 0 { + return CollectTaskResult{}, invalidInput("没有选择蝦皮商品") + } + links, err := repository.ListShopeePddLinksByGoodsIDs(db, shopeeGoodsIDs) + if err != nil { + return CollectTaskResult{}, err + } + + var skipped CollectTaskResult + pddGoodsIDs := make([]string, 0, len(shopeeGoodsIDs)) + for _, shopeeGoodsID := range shopeeGoodsIDs { + pddGoodsID, exists := links[shopeeGoodsID] + if !exists { + skipped.SkippedShopeeMissing++ + continue + } + if strings.TrimSpace(pddGoodsID) == "" { + skipped.SkippedUnlinked++ + continue + } + pddGoodsIDs = append(pddGoodsIDs, pddGoodsID) + } + + result, err := CreatePddCollectTasksForUser(db, actor, dedupe(pddGoodsIDs), clientID) + if err != nil { + return CollectTaskResult{}, err + } + result.SkippedShopeeMissing = skipped.SkippedShopeeMissing + result.SkippedUnlinked = skipped.SkippedUnlinked + return result, nil +} + // AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。 func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) { context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID)) diff --git a/admin/service/shopee_pdd_test.go b/admin/service/shopee_pdd_test.go index e36fcb4..003d830 100644 --- a/admin/service/shopee_pdd_test.go +++ b/admin/service/shopee_pdd_test.go @@ -3,6 +3,7 @@ package service import ( "database/sql" "errors" + "strings" "testing" "time" @@ -120,6 +121,92 @@ func TestCreateShopeePddCollectTaskForUser_记录当前用户(t *testing.T) { } } +func TestCreateShopeePddCollectTasksForUser_按Pdd去重并分类跳过(t *testing.T) { + db := newTestDB(t) + admin, _, _ := insertTaskUsers(t, db) + for _, goodsID := range []string{"S-A", "S-B", "S-UNLINKED", "S-DELETED"} { + seedShopeeProduct(t, db, goodsID, goodsID) + } + for _, goodsID := range []string{"S-A", "S-B"} { + if _, err := AssociateShopeePdd(db, goodsID, pddURLA, false); err != nil { + t.Fatal(err) + } + } + if _, err := AssociateShopeePdd(db, "S-DELETED", pddURLB, false); err != nil { + t.Fatal(err) + } + if err := repository.SoftDeletePddProduct(db, "937122477375"); err != nil { + t.Fatal(err) + } + + result, err := CreateShopeePddCollectTasksForUser(db, admin, + []string{"S-A", "S-B", "S-A", "S-UNLINKED", "S-DELETED", "S-MISSING"}, "") + if err != nil { + t.Fatal(err) + } + if result.Created != 1 || result.SkippedUnlinked != 1 || result.SkippedShopeeMissing != 1 || result.SkippedDeleted != 1 { + t.Fatalf("批量分类或 PDD 去重错误: %+v", result) + } + 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) + } + message := FormatCollectTaskMessage(result) + for _, want := range []string{"已创建 1 个", "1 个蝦皮商品已不存在", "1 个未关联 PDD", "1 个已删除的"} { + if !strings.Contains(message, want) { + t.Errorf("批量结果提示 %q 缺少 %q", message, want) + } + } +} + +func TestCreateShopeePddCollectTasksForUser_已采集和正常采集中分别跳过(t *testing.T) { + db := newTestDB(t) + admin, _, _ := insertTaskUsers(t, db) + seedShopeeProduct(t, db, "S-COLLECTED", "已采集") + seedShopeeProduct(t, db, "S-COLLECTING", "采集中") + AssociateShopeePdd(db, "S-COLLECTED", pddURLA, false) + AssociateShopeePdd(db, "S-COLLECTING", pddURLB, false) + if err := repository.SetCollectResult(db, "737116531267", "商品", "店铺", collectedThreeDimensions); err != nil { + t.Fatal(err) + } + if result, err := CreatePddCollectTasksForUser(db, admin, []string{"937122477375"}, ""); err != nil || result.Created != 1 { + t.Fatalf("准备采集中任务失败: result=%+v err=%v", result, err) + } + + result, err := CreateShopeePddCollectTasksForUser(db, admin, + []string{"S-COLLECTED", "S-COLLECTING"}, "") + if err != nil || result.Created != 0 || result.SkippedCollected != 1 || result.SkippedCollecting != 1 { + t.Fatalf("状态分类错误: result=%+v err=%v", result, err) + } +} + +func TestCreateShopeePddCollectTasksForUser_拒绝不可见客户端且整批不创建(t *testing.T) { + db := newTestDB(t) + admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) + if err := RegisterClient(db, model.Client{ClientID: "client-batch-b", Name: "B 的客户端"}, true); err != nil { + t.Fatal(err) + } + if _, _, err := AssignClient(db, admin, "client-batch-b", buyerB.UserID, time.Now()); err != nil { + t.Fatal(err) + } + seedShopeeProduct(t, db, "S-PERM", "权限商品") + AssociateShopeePdd(db, "S-PERM", pddURLA, false) + + _, err := CreateShopeePddCollectTasksForUser(db, buyerA, []string{"S-PERM"}, "client-batch-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) + } + product, _ := repository.GetPddProductByGoodsID(db, "737116531267") + if product.CollectStatus != model.CollectPending { + t.Fatalf("权限失败后商品状态应保持 pending,实际 %s", product.CollectStatus) + } +} + func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) { db := newTestDB(t) admin, _, _ := insertTaskUsers(t, db) diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index d9ea833..f30a139 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -4,6 +4,16 @@ {{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
{{if .CurrentUser.IsAdmin}}导入记录{{end}} + + + + + + + + +
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, 见 docs/admin/05-ui-specification.md §4.1、工单 #43。 @@ -77,7 +87,7 @@ {{range .Rows}} - + {{.GoodsID}} {{if .ImageURL}}{{else}}无图{{end}} {{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css), @@ -111,6 +121,32 @@
+ +

diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 30d9cff..1089e67 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -87,8 +87,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 ### 4.1 蝦皮数据模块 -**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、 -商品 ID 搜索框、搜索按钮、删除按钮。采集从商品详情中发起,避免脱离商品上下文。 +**顶部工具条:** 导入按钮、批量创建 PDD 采集任务按钮、状态筛选 +(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、商品 ID 搜索框、搜索按钮、删除按钮。 `[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护 (找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话, @@ -100,7 +100,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 | 列 | 说明 | |---|---| -| 勾选 | 支持批量删除 | +| 勾选 | 支持批量删除、批量创建 PDD 采集任务 | | 商品 ID | 蝦皮商品编号 | | 商品名称 | | | 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 | @@ -114,7 +114,11 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 - 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联; - 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用; - 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则; -- `[必须]` 采集按钮**在本页只出现在弹窗里**,不要放在每个表格行—— +- 蝦皮列表支持勾选商品后批量创建 PDD 采集任务。系统按蝦皮商品当前关联取得 + PDD 商品并按 PDD 商品 ID 去重,可选当前账号可见客户端;未关联、PDD 已删除、 + 已采集和正常采集中的商品按原因统计并跳过。采集失败、采集超时或采集中但已无 + 有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口; +- `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行—— 一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。 `[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页; @@ -527,7 +531,7 @@ MVP 之后: - PDD 链接**人工填写**,不自动抓取。 - PDD 商品是**独立模块**,创建商品和创建采集任务都在那里; 蝦皮数据模块的编辑弹窗负责的是「这个蝦皮商品对应哪个 PDD 商品」。 -- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。 +- 采集任务和采购任务都允许指定当前账号可见的客户端,也允许留空;留空时由符合条件的客户端领取。 - 任务**分配给指定客户端**,Client 只领分给自己的。 - **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。 - 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 4babcda..7dd17cb 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -136,7 +136,7 @@ ### 4.1 工具条 ```text -[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除] +[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除] ``` - **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。 @@ -166,6 +166,9 @@ - `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相 组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗 和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。 +- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择 + 数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID + 去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。 ### 4.2 表格列