feat: 统一蝦皮与顺运宝 PDD 关联入口 (#68)
This commit is contained in:
@@ -565,6 +565,27 @@ func (h *Handler) SybSelectSKU(c *gin.Context) {
|
||||
h.sybRedirect(c, "蝦皮规格已确认,下一步按处理状态继续。")
|
||||
}
|
||||
|
||||
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
|
||||
func (h *Handler) SybAssociatePdd(c *gin.Context) {
|
||||
goodsID, err := service.AssociateSybPdd(h.db, c.PostForm("syb_id"), c.PostForm("pdd_url"),
|
||||
c.PostForm("confirm_replace") == "1")
|
||||
if err != nil {
|
||||
h.sybRedirect(c, "PDD 关联未保存:"+err.Error()+"。请核对链接后重试。")
|
||||
return
|
||||
}
|
||||
h.sybRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID))
|
||||
}
|
||||
|
||||
// SybCollectPdd 为货运单当前关联的 PDD 商品创建采集任务。
|
||||
func (h *Handler) SybCollectPdd(c *gin.Context) {
|
||||
result, err := service.CreateSybPddCollectTask(h.db, c.PostForm("syb_id"))
|
||||
if err != nil {
|
||||
h.sybRedirect(c, "采集任务未创建:"+err.Error())
|
||||
return
|
||||
}
|
||||
h.sybRedirect(c, service.FormatCollectTaskMessage(result))
|
||||
}
|
||||
|
||||
// SybMatch 保存规格匹配结果。
|
||||
//
|
||||
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
|
||||
|
||||
@@ -38,6 +38,24 @@ func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, want
|
||||
}
|
||||
|
||||
func Test列表写操作跳转保留筛选和页码(t *testing.T) {
|
||||
t.Run("蝦皮", func(t *testing.T) {
|
||||
context, recorder := listPostContext(t, "/shopee/save", url.Values{
|
||||
"list_goods_id": {"1001"}, "status": {"no_link"}, "page": {"2"},
|
||||
})
|
||||
(&Handler{}).shopeeRedirect(context, "完成")
|
||||
assertRedirectQuery(t, recorder, "/shopee", map[string]string{
|
||||
"goods_id": "1001", "status": "no_link", "page": "2", "msg": "完成",
|
||||
})
|
||||
})
|
||||
t.Run("顺运宝", func(t *testing.T) {
|
||||
context, recorder := listPostContext(t, "/syb/associate-pdd", url.Values{
|
||||
"order_no": {"SYB-1"}, "stage": {"pdd_missing"}, "page": {"3"},
|
||||
})
|
||||
(&Handler{}).sybRedirect(context, "完成")
|
||||
assertRedirectQuery(t, recorder, "/syb", map[string]string{
|
||||
"order_no": "SYB-1", "stage": "pdd_missing", "page": "3", "msg": "完成",
|
||||
})
|
||||
})
|
||||
t.Run("PDD", func(t *testing.T) {
|
||||
context, recorder := listPostContext(t, "/pdd/delete", url.Values{
|
||||
"q": {"737"}, "status": {"pending"}, "page": {"3"},
|
||||
|
||||
+47
-20
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,8 +30,6 @@ func (h *Handler) ShopeeList(c *gin.Context) {
|
||||
// 复用 #18 的弹窗机制:行上写 data-detail-id="{{.GoodsID}}",
|
||||
// 前端拿 goods_id 拼到 data-detail-url 后面,以 "id" 作为查询参数名
|
||||
// (固定写法,见 static/js/app.js「双击行打开详情弹窗」)。
|
||||
//
|
||||
// `[必须]` 这个弹窗只读,不含保存表单——ShopeeSave 仍是 501,见 #41。
|
||||
func (h *Handler) ShopeeDetail(c *gin.Context) {
|
||||
goodsID := strings.TrimSpace(c.Query("id"))
|
||||
if goodsID == "" {
|
||||
@@ -48,7 +47,11 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{"D": detail})
|
||||
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{
|
||||
"D": detail, "CSRFToken": csrfToken(c),
|
||||
"Keyword": c.Query("goods_id"), "StatusFilter": c.Query("status"),
|
||||
"CurrentPage": service.ParsePage(c.Query("page")),
|
||||
})
|
||||
}
|
||||
|
||||
// ShopeeImport 处理 Excel 上传导入。
|
||||
@@ -182,9 +185,19 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw,
|
||||
"IsFiltered": result.IsFiltered,
|
||||
"Failures": failures,
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
|
||||
}))
|
||||
}
|
||||
|
||||
func detailValuesForShopee(values url.Values, pageNum int) string {
|
||||
detailValues := url.Values{}
|
||||
for key, entries := range values {
|
||||
detailValues[key] = append([]string(nil), entries...)
|
||||
}
|
||||
detailValues.Set("page", strconv.Itoa(pageNum))
|
||||
return detailValues.Encode()
|
||||
}
|
||||
|
||||
// shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
|
||||
//
|
||||
// `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数,
|
||||
@@ -198,22 +211,15 @@ func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) str
|
||||
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
|
||||
}
|
||||
|
||||
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
||||
//
|
||||
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
||||
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
|
||||
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
|
||||
//
|
||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||
// ShopeeSave 保存蝦皮商品当前关联的 PDD 商品。
|
||||
func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
|
||||
// 由它去建 / 复用 / 复活 pdd_products 那一行。
|
||||
//
|
||||
// **采集状态不在蝦皮这边**:它属于 PDD 商品(pdd_products.collect_status)。
|
||||
// "还没填链接"由 shopee_products.pdd_goods_id IS NULL 表达——
|
||||
// 没填链接时 pdd_products 里根本不会有那一行。
|
||||
// 不要写 no_link,那个值在 #16 已从 CHECK 约束里删掉了,写了会直接撞约束。
|
||||
fail(c, http.StatusNotImplemented, "保存功能尚未实现。")
|
||||
goodsID, err := service.AssociateShopeePdd(h.db, c.PostForm("shopee_goods_id"),
|
||||
c.PostForm("pdd_url"), c.PostForm("confirm_replace") == "1")
|
||||
if err != nil {
|
||||
h.shopeeRedirect(c, "PDD 关联未保存:"+err.Error()+"。请核对链接后重试。")
|
||||
return
|
||||
}
|
||||
h.shopeeRedirect(c, fmt.Sprintf("已关联 PDD 商品 %s,可继续创建采集任务", goodsID))
|
||||
}
|
||||
|
||||
// ShopeeDelete 批量删除勾选的行。
|
||||
@@ -236,6 +242,27 @@ func (h *Handler) ShopeeDelete(c *gin.Context) {
|
||||
//
|
||||
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
|
||||
func (h *Handler) ShopeeCollect(c *gin.Context) {
|
||||
// TODO(骨架): 调 service.CreateCollectTasks(goodsIDs, clientID)
|
||||
fail(c, http.StatusNotImplemented, "采集功能尚未实现。")
|
||||
result, err := service.CreateShopeePddCollectTask(h.db, c.PostForm("shopee_goods_id"))
|
||||
if err != nil {
|
||||
h.shopeeRedirect(c, "采集任务未创建:"+err.Error())
|
||||
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 != "" {
|
||||
params.Set("goods_id", value)
|
||||
}
|
||||
if value := strings.TrimSpace(c.PostForm("status")); value != "" {
|
||||
params.Set("status", service.ParseShopeeStatus(value))
|
||||
}
|
||||
if value := strings.TrimSpace(c.PostForm("page")); value != "" {
|
||||
params.Set("page", value)
|
||||
}
|
||||
if msg != "" {
|
||||
params.Set("msg", msg)
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/shopee?"+params.Encode())
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
pages.POST("/syb/sync", h.SybSync)
|
||||
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
|
||||
pages.POST("/syb/select-sku", h.SybSelectSKU)
|
||||
pages.POST("/syb/associate-pdd", h.SybAssociatePdd)
|
||||
pages.POST("/syb/collect-pdd", h.SybCollectPdd)
|
||||
pages.POST("/syb/match", h.SybMatch)
|
||||
pages.POST("/syb/create-task", h.SybCreateTask)
|
||||
pages.POST("/syb/delete", h.SybDelete)
|
||||
|
||||
@@ -244,6 +244,27 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。
|
||||
// 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。
|
||||
func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error {
|
||||
result, err := q.Exec(`
|
||||
UPDATE shopee_products
|
||||
SET pdd_goods_id = ?, pdd_goods_url = ?, updated_at = ?
|
||||
WHERE goods_id = ?`,
|
||||
pddGoodsID, pddURL, model.NowISO(), shopeeGoodsID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取蝦皮商品 %s 的更新结果失败: %w", shopeeGoodsID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("蝦皮商品 %s 不存在", shopeeGoodsID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。
|
||||
// pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时,
|
||||
// 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。
|
||||
|
||||
@@ -192,18 +192,19 @@ type ShopeeSpecView struct {
|
||||
}
|
||||
|
||||
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
|
||||
//
|
||||
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
|
||||
// ShopeeSave 仍是 501,见 #41。
|
||||
type ShopeeProductDetail struct {
|
||||
GoodsID string
|
||||
Title string
|
||||
ShopeeStatus string
|
||||
MainSKUCode string
|
||||
|
||||
PddURL string
|
||||
PddMissing bool
|
||||
StatusText string
|
||||
PddURL string
|
||||
PddGoodsID string
|
||||
PddMissing bool
|
||||
StatusText string
|
||||
CollectStatus string
|
||||
CollectMsg string
|
||||
CanCollect bool
|
||||
|
||||
SKUs []ShopeeSpecView
|
||||
PendingCount int
|
||||
@@ -222,6 +223,7 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
|
||||
Title: p.Title,
|
||||
ShopeeStatus: p.ShopeeStatus,
|
||||
MainSKUCode: p.MainSKUCode,
|
||||
PddGoodsID: p.PddGoodsID,
|
||||
}
|
||||
if d.Title == "" {
|
||||
d.Title = placeholder
|
||||
@@ -239,12 +241,16 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
|
||||
d.StatusText = "未填链接"
|
||||
} else {
|
||||
d.PddURL = p.PddGoodsURL
|
||||
status, _, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
|
||||
status, msg, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status.Valid {
|
||||
d.CollectStatus = status.String
|
||||
d.CollectMsg = msg.String
|
||||
d.StatusText = collectStatusText(model.CollectStatus(status.String))
|
||||
d.CanCollect = status.String == string(model.CollectPending) ||
|
||||
status.String == string(model.CollectFailed)
|
||||
} else {
|
||||
d.StatusText = "PDD 商品缺失"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// ErrPddAssociationReplaceRequired 表示当前蝦皮商品已经关联了另一个 PDD 商品。
|
||||
var ErrPddAssociationReplaceRequired = errors.New("更换 PDD 商品需要明确确认")
|
||||
|
||||
// AssociateShopeePdd 把蝦皮商品关联到 PDD 商品档案。
|
||||
// 解析、建档/复活和关联更新在同一事务内完成,任一步失败都不留下半成品。
|
||||
func AssociateShopeePdd(db *sql.DB, shopeeGoodsID, rawURL string, confirmReplace bool) (string, error) {
|
||||
shopeeGoodsID = strings.TrimSpace(shopeeGoodsID)
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
pddGoodsID, err := ParsePddGoodsID(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("开始关联 PDD 商品事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
product, err := repository.GetShopeeProductByGoodsID(tx, shopeeGoodsID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if product == nil {
|
||||
return "", fmt.Errorf("蝦皮商品 %s 不存在", shopeeGoodsID)
|
||||
}
|
||||
if product.PddGoodsID != "" && product.PddGoodsID != pddGoodsID && !confirmReplace {
|
||||
return "", fmt.Errorf("%w:当前是 %s,新链接解析为 %s",
|
||||
ErrPddAssociationReplaceRequired, product.PddGoodsID, pddGoodsID)
|
||||
}
|
||||
if _, err := repository.EnsurePddProduct(tx, pddGoodsID, rawURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := repository.UpdateShopeePddLink(tx, shopeeGoodsID, pddGoodsID, rawURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", fmt.Errorf("提交 PDD 商品关联失败: %w", err)
|
||||
}
|
||||
return pddGoodsID, nil
|
||||
}
|
||||
|
||||
// CreateShopeePddCollectTask 按蝦皮商品当前关联创建采集任务,避免相信表单里的 PDD ID。
|
||||
func CreateShopeePddCollectTask(db *sql.DB, shopeeGoodsID string) (CollectTaskResult, error) {
|
||||
product, err := repository.GetShopeeProductByGoodsID(db, strings.TrimSpace(shopeeGoodsID))
|
||||
if err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
if product == nil {
|
||||
return CollectTaskResult{}, fmt.Errorf("蝦皮商品不存在")
|
||||
}
|
||||
if product.PddGoodsID == "" {
|
||||
return CollectTaskResult{}, fmt.Errorf("请先关联 PDD 商品")
|
||||
}
|
||||
return CreatePddCollectTasks(db, []string{product.PddGoodsID})
|
||||
}
|
||||
|
||||
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
|
||||
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
|
||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if context == nil {
|
||||
return "", fmt.Errorf("顺运宝明细不存在")
|
||||
}
|
||||
if context.Order.ShopeeSKUID == "" {
|
||||
return "", fmt.Errorf("请先确认蝦皮规格")
|
||||
}
|
||||
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
|
||||
}
|
||||
|
||||
// CreateSybPddCollectTask 按顺运宝明细当前关联创建采集任务。
|
||||
func CreateSybPddCollectTask(db *sql.DB, sybID string) (CollectTaskResult, error) {
|
||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||
if err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
if context == nil {
|
||||
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
|
||||
}
|
||||
return CreateShopeePddCollectTask(db, context.Order.ShopeeGoodsID)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
pddURLA = "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
|
||||
pddURLB = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||
)
|
||||
|
||||
func TestAssociateShopeePdd_新建并重复复用(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
got, err := AssociateShopeePdd(db, "1001", pddURLA, false)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次关联失败: %v", i+1, err)
|
||||
}
|
||||
if got != "737116531267" {
|
||||
t.Fatalf("goods_id = %q", got)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, "737116531267").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("同一 PDD 商品建了 %d 条,想要 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateShopeePdd_无效链接不写任何数据(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
if _, err := AssociateShopeePdd(db, "1001", "https://p.pinduoduo.com/short", false); err == nil {
|
||||
t.Fatal("短链接应被拒绝")
|
||||
}
|
||||
var pddCount int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&pddCount)
|
||||
product, _ := repository.GetShopeeProductByGoodsID(db, "1001")
|
||||
if pddCount != 0 || product.PddGoodsID != "" {
|
||||
t.Fatalf("失败后仍写入:pdd=%d, link=%q", pddCount, product.PddGoodsID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateShopeePdd_换商品必须确认且旧档案保留(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AssociateShopeePdd(db, "1001", pddURLB, false); !errors.Is(err, ErrPddAssociationReplaceRequired) {
|
||||
t.Fatalf("未确认换品错误 = %v", err)
|
||||
}
|
||||
product, _ := repository.GetShopeeProductByGoodsID(db, "1001")
|
||||
if product.PddGoodsID != "737116531267" {
|
||||
t.Fatalf("未确认时关联被改成 %q", product.PddGoodsID)
|
||||
}
|
||||
if _, err := AssociateShopeePdd(db, "1001", pddURLB, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
product, _ = repository.GetShopeeProductByGoodsID(db, "1001")
|
||||
if product.PddGoodsID != "937122477375" {
|
||||
t.Fatalf("确认后关联 = %q", product.PddGoodsID)
|
||||
}
|
||||
old, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
if old == nil {
|
||||
t.Fatal("换品不应删除旧 PDD 档案")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateShopeePdd_软删除商品可复活(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
repository.EnsurePddProduct(db, "737116531267", pddURLA)
|
||||
if _, err := repository.SoftDeletePddProducts(db, []string{"737116531267"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AssociateShopeePdd(db, "1001", pddURLA, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||
if p == nil || p.IsDeleted() {
|
||||
t.Fatal("关联应复活软删除的 PDD 商品")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShopeePddCollectTask_按当前关联创建(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
AssociateShopeePdd(db, "1001", pddURLA, false)
|
||||
result, err := CreateShopeePddCollectTask(db, "1001")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("创建结果 = %+v, err=%v", result, err)
|
||||
}
|
||||
_, _, _, ok := collectTaskOf(t, db, "737116531267")
|
||||
if !ok {
|
||||
t.Fatal("没有创建采集任务")
|
||||
}
|
||||
}
|
||||
@@ -1177,6 +1177,11 @@ type SybProcessingDetail struct {
|
||||
StageHelp string
|
||||
Candidates []SybSKUCandidateView
|
||||
ShopeeExists bool
|
||||
PddGoodsID string
|
||||
PddURL string
|
||||
CollectStatus string
|
||||
CollectMsg string
|
||||
CanCollect bool
|
||||
}
|
||||
|
||||
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
|
||||
@@ -1189,7 +1194,12 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
||||
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
|
||||
ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID,
|
||||
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
|
||||
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
|
||||
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
|
||||
}
|
||||
d.CanCollect = context.PddGoodsID != "" &&
|
||||
(context.PddCollectStatus == string(model.CollectPending) ||
|
||||
context.PddCollectStatus == string(model.CollectFailed))
|
||||
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
||||
if context.ShopeeExists {
|
||||
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)
|
||||
|
||||
@@ -1,61 +1,65 @@
|
||||
{{define "shopee/detail_modal"}}
|
||||
{{/* 双击一行时弹出的内容。
|
||||
这是一个**片段**,不是整页——外面的弹窗壳子在 shopee/list.html 里。
|
||||
|
||||
`[必须]` 这个弹窗只读,没有保存表单——ShopeeSave 仍是 501,
|
||||
不要放一个点了没反应的保存按钮,见工单 #41。 */}}
|
||||
{{with .D}}
|
||||
<div class="modal-head">
|
||||
<h2>商品 {{.GoodsID}}</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-body form-stack">
|
||||
<dl class="detail">
|
||||
<dt>商品名称</dt><dd>{{.Title}}</dd>
|
||||
<dt>蝦皮状态</dt><dd>{{.ShopeeStatus}}</dd>
|
||||
<dt>主商品货号</dt><dd>{{.MainSKUCode}}</dd>
|
||||
{{/* PDD 链接本工单只读展示,不做保存,见 #41「不做」清单 */}}
|
||||
<dt>PDD 链接</dt>
|
||||
<dd{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</dd>
|
||||
<dt>采集状态</dt><dd>{{.StatusText}}</dd>
|
||||
<dt>PDD 商品 ID</dt><dd{{if .PddMissing}} class="missing"{{end}}>{{if .PddGoodsID}}{{.PddGoodsID}}{{else}}未关联{{end}}</dd>
|
||||
<dt>采集状态</dt><dd>{{.StatusText}}{{if .CollectMsg}}:{{.CollectMsg}}{{end}}</dd>
|
||||
</dl>
|
||||
|
||||
<h3>
|
||||
规格({{len .SKUs}} 个{{if gt .PendingCount 0}},{{.PendingCount}} 个待人工补{{end}})
|
||||
</h3>
|
||||
<form method="post" action="/shopee/save" class="form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="field">
|
||||
<label for="shopee-pdd-url">PDD 商品链接</label>
|
||||
<input id="shopee-pdd-url" type="url" name="pdd_url" value="{{.PddURL}}" required placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=...">
|
||||
<small>保存后会按链接中的 goods_id 建立或复用 PDD 商品档案。</small>
|
||||
</div>
|
||||
{{if .PddGoodsID}}
|
||||
<label class="check-line">
|
||||
<input type="checkbox" name="confirm_replace" value="1">
|
||||
如果新链接是其他商品,我确认更换当前关联(现有规格映射不会删除)
|
||||
</label>
|
||||
{{end}}
|
||||
<div><button type="submit" class="primary">保存 PDD 关联</button></div>
|
||||
</form>
|
||||
|
||||
{{if .PddGoodsID}}
|
||||
<form method="post" action="/shopee/collect" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
{{if .CanCollect}}
|
||||
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
|
||||
{{else}}
|
||||
<span class="hint">{{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,可继续处理顺运宝规格映射。{{end}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<h3>规格({{len .SKUs}} 个{{if gt .PendingCount 0}},{{.PendingCount}} 个待人工补{{end}})</h3>
|
||||
{{if not .SKUs}}
|
||||
<p class="missing">这个商品在报表里没有任何 SKU。</p>
|
||||
{{else}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>规格ID</th>
|
||||
<th>颜色</th>
|
||||
<th>尺码</th>
|
||||
<th>建議</th>
|
||||
{{/* 状态列要有文字(✓ / 待补),不能只靠颜色区分,见 #41 */}}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>规格ID</th><th>颜色</th><th>尺码</th><th>建議</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .SKUs}}
|
||||
<tr{{if .Pending}} class="row-warn"{{end}}>
|
||||
<td>{{.SKUID}}</td>
|
||||
<td>{{.Color}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.Advice}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
</tr>
|
||||
{{if .Pending}}
|
||||
{{/* `[必须]` 待补的行要显示 spec_raw 原文——不显示的话人工不知道
|
||||
该填什么,这正是保留原文这个设计的全部意义,见 #41。 */}}
|
||||
<tr class="row-warn">
|
||||
<td colspan="5"><small>原文:{{.SpecRaw}}</small></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr{{if .Pending}} class="row-warn"{{end}}><td>{{.SKUID}}</td><td>{{.Color}}</td><td>{{.Size}}</td><td>{{.Advice}}</td><td>{{.StatusText}}</td></tr>
|
||||
{{if .Pending}}<tr class="row-warn"><td colspan="5"><small>原文:{{.SpecRaw}}</small></td></tr>{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -63,8 +67,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>关闭</button>
|
||||
</div>
|
||||
<div class="modal-foot"><button type="button" data-modal-close>关闭</button></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -9,11 +9,6 @@
|
||||
<button type="submit">导入 Excel</button>
|
||||
</form>
|
||||
|
||||
<form class="inline" method="post" action="/shopee/collect">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" data-need-checked>批量采集</button>
|
||||
</form>
|
||||
|
||||
<form class="inline grow" method="get" action="/shopee">
|
||||
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
||||
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
||||
@@ -128,11 +123,8 @@
|
||||
复用 #18 的机制(data-detail-url / data-detail-slot / data-detail-id),
|
||||
app.js 不需要改,见工单 #41。
|
||||
|
||||
这个弹窗**只读**:商品信息 + 完整规格表,没有保存按钮。
|
||||
从蝦皮商品出发录入 PDD 链接、发起采集的入口(含 ShopeeSave/
|
||||
ShopeeCollect 的实现)由后续的「蝦皮↔PDD 关联入口」工单负责,
|
||||
本工单不做,ShopeeSave/ShopeeDelete/ShopeeCollect 仍是 501。 */}}
|
||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="/shopee/detail" hidden>
|
||||
弹窗同时提供当前 PDD 关联和单商品采集入口。 */}}
|
||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
|
||||
<div class="modal-body">正在加载…</div>
|
||||
</div>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
{{if not .ShopeeExists}}
|
||||
<p class="missing">蝦皮数据中找不到商品 {{.ShopeeGoodsID}}。请先导入该商品,或核对顺运宝商品链接提取出的 ID。</p>
|
||||
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据查看</a></p>
|
||||
{{else if not .Candidates}}
|
||||
{{else if and (not .ShopeeSKUID) (not .Candidates)}}
|
||||
<p class="missing">这个蝦皮商品没有可选 SKU。请先在蝦皮数据中补充规格。</p>
|
||||
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据补充</a></p>
|
||||
{{else}}
|
||||
{{else if not .ShopeeSKUID}}
|
||||
<form method="post" action="/syb/select-sku" class="form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
@@ -44,6 +44,39 @@
|
||||
<button type="submit" class="primary">确认蝦皮规格</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<h3>关联采购商品</h3>
|
||||
<form method="post" action="/syb/associate-pdd" class="form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
<input type="hidden" name="order_no" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="field">
|
||||
<label for="syb-pdd-url">PDD 商品链接</label>
|
||||
<input id="syb-pdd-url" type="url" name="pdd_url" value="{{.PddURL}}" required placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=...">
|
||||
<small>{{if .PddGoodsID}}当前 PDD 商品 ID:{{.PddGoodsID}}{{else}}保存后会从完整链接解析商品 ID,并建立 PDD 商品档案。{{end}}</small>
|
||||
</div>
|
||||
{{if .PddGoodsID}}
|
||||
<label class="check-line"><input type="checkbox" name="confirm_replace" value="1">如果新链接是其他商品,我确认更换当前关联</label>
|
||||
{{end}}
|
||||
<div><button type="submit" class="primary">保存 PDD 关联</button></div>
|
||||
</form>
|
||||
|
||||
{{if .PddGoodsID}}
|
||||
<form method="post" action="/syb/collect-pdd" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
<input type="hidden" name="order_no" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
{{if .CanCollect}}
|
||||
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
|
||||
{{else}}
|
||||
<span class="hint">{{if eq .CollectStatus "collecting"}}采集任务正在执行,请稍后刷新。{{else}}PDD 数据已采集,下一步匹配采购规格。{{end}}</span>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
### 4.1 蝦皮数据模块
|
||||
|
||||
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
|
||||
商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
|
||||
商品 ID 搜索框、搜索按钮、删除按钮。采集从商品详情中发起,避免脱离商品上下文。
|
||||
|
||||
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
||||
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
||||
@@ -100,7 +100,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
|
||||
| 列 | 说明 |
|
||||
|---|---|
|
||||
| 勾选 | 支持批量删除、批量采集 |
|
||||
| 勾选 | 支持批量删除 |
|
||||
| 商品 ID | 蝦皮商品编号 |
|
||||
| 商品名称 | |
|
||||
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
|
||||
@@ -110,15 +110,15 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
|
||||
**双击行打开编辑弹窗**,这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口:
|
||||
|
||||
- 可编辑:PDD 链接、颜色、尺码、建议;
|
||||
- 弹窗内提供【采集】按钮,紧挨 PDD 链接输入框;
|
||||
- 可编辑当前 PDD 商品链接;规格表继续只读展示;
|
||||
- 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联;
|
||||
- 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用;
|
||||
- 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则;
|
||||
- `[必须]` 采集按钮**在本页只出现在弹窗里**,不要放在每个表格行——
|
||||
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
||||
|
||||
`[注意]` PDD 商品模块(§4.2)也能录入链接和发起采集,两处并存。
|
||||
本页的入口目前**还是骨架**(点了返回 501),
|
||||
两者要不要合并、以哪个为准,属于「蝦皮↔PDD 关联入口」的范围,
|
||||
由那张工单统一决定,不要在本模块单独改。
|
||||
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
|
||||
蝦皮详情和顺运宝处理详情是带业务上下文的关联入口,两者调用同一个 Service。
|
||||
|
||||
**其他要求:**
|
||||
|
||||
@@ -210,6 +210,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
|
||||
- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。
|
||||
- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。
|
||||
- 蝦皮 SKU 确认后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
|
||||
|
||||
- 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码;
|
||||
- 操作员选好对应关系,点保存;
|
||||
|
||||
@@ -124,14 +124,12 @@
|
||||
### 4.1 工具条
|
||||
|
||||
```text
|
||||
[导入 Excel] [批量采集] 状态[全部▾] 商品ID [________] [搜索] [删除]
|
||||
[导入 Excel] 状态[全部▾] 商品ID [________] [搜索] [删除]
|
||||
```
|
||||
|
||||
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
|
||||
(导入 5195 商品 / 6092 SKU,失败 N 行)。
|
||||
`[必须]` 失败行要列出行号和原因,不能静默跳过。
|
||||
- **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。
|
||||
`[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。
|
||||
- **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。
|
||||
这个筛选是**刚需**,不是锦上添花——本页主要用途是维护
|
||||
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID
|
||||
@@ -166,10 +164,8 @@
|
||||
|
||||
这是**从蝦皮商品出发**填 PDD 链接、发起采集的入口。
|
||||
|
||||
> `[注意]` PDD 商品页([§5](#5-pdd-商品页))也能录入链接和发起采集,
|
||||
> 两处并存。本页的入口目前**还是骨架**(点了返回 501),
|
||||
> 两者要不要合并、以哪个为准,属于「蝦皮↔PDD 关联入口」的范围,
|
||||
> 由那张工单统一决定,不要在本页单独改。
|
||||
> PDD 商品页([§5](#5-pdd-商品页))负责商品档案和诊断;本弹窗负责在
|
||||
> 蝦皮商品上下文中维护“当前 PDD 商品”并发起单商品采集。顺运宝弹窗复用同一逻辑。
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
@@ -182,18 +178,19 @@
|
||||
│ 尺码 [M_______________] │
|
||||
│ 建议 [40-50公斤_______] │
|
||||
│ │
|
||||
│ PDD 链接 [___________________] [采集] │ ← ★
|
||||
│ PDD 链接 [___________________________] │ ← ★
|
||||
│ 采集状态 已采集(2026-08-06 15:20) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [取消] [保存] │
|
||||
│ [创建采集任务] [关闭] [保存关联] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`[必须]` 几条硬规则:
|
||||
|
||||
- **本页的采集按钮只在这个弹窗里出现**,不要放到表格每一行。一个商品有 N 个 SKU 行,
|
||||
放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
||||
- **创建采集任务只在这个弹窗里出现**,不要放到表格每一行或工具条,确保操作员看得到当前关联商品。
|
||||
- **规格原文只读且永远显示**。操作员靠它判断颜色尺码该怎么填。
|
||||
- 保存链接时展示当前解析出的 PDD 商品 ID;新链接指向其他商品时必须勾选明确确认。
|
||||
- 无法解析 `goods_id` 的链接整体不写入;同一商品复用档案,软删除档案可复活。
|
||||
- PDD 链接为空时**采集按钮置灰**,旁边提示"请先填写 PDD 链接"。
|
||||
- 采集状态是"采集中"时按钮置灰,提示"已在采集中"。
|
||||
- 采集失败时显示错误原因,按钮变成"重新采集"。
|
||||
@@ -491,6 +488,10 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
||||
`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。
|
||||
后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。
|
||||
|
||||
确认蝦皮 SKU 后,弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
|
||||
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
|
||||
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 规格匹配 · 订单 SO-20260806-001 │
|
||||
|
||||
Reference in New Issue
Block a user