feat: 统一蝦皮与顺运宝 PDD 关联入口 (#68)

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