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 商品缺失"处理。
+13 -7
View File
@@ -192,18 +192,19 @@ type ShopeeSpecView struct {
} }
// ShopeeProductDetail 是双击弹窗要显示的全部内容。 // ShopeeProductDetail 是双击弹窗要显示的全部内容。
//
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
// ShopeeSave 仍是 501,见 #41。
type ShopeeProductDetail struct { type ShopeeProductDetail struct {
GoodsID string GoodsID string
Title string Title string
ShopeeStatus string ShopeeStatus string
MainSKUCode string MainSKUCode string
PddURL string PddURL string
PddMissing bool PddGoodsID string
StatusText string PddMissing bool
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 │