feat: 支持顺运宝批量创建采集任务 (#153)

This commit is contained in:
chengma
2026-08-11 14:59:26 +08:00
parent 2629e41211
commit b527204204
12 changed files with 341 additions and 14 deletions
+21
View File
@@ -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),
+23
View File
@@ -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())
}
}
+1
View File
@@ -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)
+31
View File
@@ -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 {
+28
View File
@@ -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)
}
+92
View File
@@ -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)
}
}
+11
View File
@@ -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)
+41
View File
@@ -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)
}
}
+16 -8
View File
@@ -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;
+16
View File
@@ -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)
+49 -3
View File
@@ -22,7 +22,18 @@
<button type="button" data-modal-open="sync-history-modal">同步记录</button>
<button type="button" data-need-checked data-modal-open="purchase-modal" data-purchase-open>创建采购任务</button>
<form id="syb-collect-form" method="post" action="/syb/collect-pdd-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="syb-collect-form">
<input type="hidden" name="order_no" value="{{.Keyword}}" form="syb-collect-form">
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="syb-collect-form">
<input type="hidden" name="stage" value="{{.StageFilter}}" form="syb-collect-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="syb-collect-form">
<button type="button" data-modal-open="syb-collect-modal" data-collect-open
data-collect-form="syb-collect-form" data-collect-action="collect"
data-need-checked="collect">创建 PDD 采集任务</button>
<button type="button" data-need-checked="purchase" data-modal-open="purchase-modal"
data-purchase-open>创建采购任务</button>
<form class="inline grow" method="get" action="/syb">
<label for="stage">处理阶段</label>
@@ -109,8 +120,13 @@
{{range .Rows}}
<tr {{if .NeedsAttention}}class="row-warn"{{end}}>
<td class="col-check">
<input type="checkbox" value="{{.SybID}}" name="ids" {{if not .CanPurchase}}disabled{{end}}
aria-label="选择货运单明细 {{.SybID}}">
<span title="{{.SelectionHint}}">
<input type="checkbox" value="{{.SybID}}" name="ids"
data-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}"
{{if .CanCollect}}form="syb-collect-form"{{end}}
{{if not (or .CanCollect .CanPurchase)}}disabled{{end}}
aria-label="选择货运单明细 {{.SybID}}:{{.SelectionHint}}">
</span>
</td>
<td>{{.SybID}}</td>
<td>{{.OrderNo}}</td>
@@ -163,6 +179,36 @@
</table>
</div>
<div class="modal-backdrop" id="syb-collect-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="syb-collect-modal-title">
<div class="modal-head">
<h2 id="syb-collect-modal-title">创建 PDD 采集任务</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p>将处理已选择的 <strong data-collect-count>0</strong> 条可采集货运单明细。</p>
<div class="field">
<label for="syb-collect-client">执行客户端</label>
<select id="syb-collect-client" name="client_id" form="syb-collect-form" autofocus>
<option value="">不指定(任意客户端可领取)</option>
{{range .AssignableClients}}
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
{{end}}
</select>
</div>
<p class="hint">
同一 PDD 商品关联多条货运单时只创建一条任务。通常保持不指定即可;
指定后任务只等待该客户端领取。
</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" form="syb-collect-form" class="primary"
data-collect-submit>确认创建</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="purchase-modal" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="purchase-modal-title">
<div class="modal-head">
+12 -3
View File
@@ -449,7 +449,7 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
```text
开始日期 [2026-08-07] 结束日期 [2026-08-09] [同步] [同步记录]
[创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [搜索] [删除]
[创建 PDD 采集任务] [创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [搜索] [删除]
```
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。通常默认最近 3 天;如果
@@ -501,7 +501,7 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。
翻页、同步、删除、详情内写操作和创建采购任务返回列表时都要保留店铺条件。
翻页、同步、删除、详情内写操作、创建 PDD 采集任务和创建采购任务返回列表时都要保留店铺条件。
### 6.2 表格列
@@ -524,6 +524,15 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
复选框按批量动作能力启用,不再等同于“可采购”:PDD 待采集、PDD 采集失败行可用于
“创建 PDD 采集任务”,可创建采购任务行可用于“创建采购任务”,其他阶段继续禁用并通过
`title` 和无障碍标签说明原因。混合勾选时两个按钮分别统计和提交自身兼容的行,不能把
待采集商品带进采购确认,也不能把采购就绪商品带进采集请求。
批量创建 PDD 采集任务的确认弹窗显示可采集明细数,并允许不指定客户端或选择当前账号
可见客户端。同一 PDD 商品可能对应多条顺运宝明细,服务端按 PDD 商品 ID 去重,只创建
一条采集任务;浏览器提交的全部明细必须重新校验阶段,参数过期或被篡改时整批拒绝。
### 6.3 采购处理与规格匹配弹窗(点击商品标题或处理类下一步打开)
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
@@ -566,7 +575,7 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
### 6.4 创建采购任务
只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号
只有“可创建采购任务”的行能参与采购批量动作。勾选后打开服务端渲染的确认弹窗:选择当前账号
可见的任意客户端,并逐行确认人民币价格上限。离线或尚未就绪的 Client 也可提前
指派,待其就绪后领取;选项继续显示在线状态但不因能力状态禁用。默认价格来自映射规格
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行