feat: 蝦皮数据页改为商品级列表与规格弹窗 (#41)

原来是 SKU 级一行,问题不在行数(6092→5195 只少 15%),在语义错了:
pdd_goods_url / pdd_goods_id 在 shopee_products 上,是商品级字段。
商品 24420648774 有 62 个 SKU,列表里 62 行显示同一个 PDD 链接,
将来双击任意一行编辑的也是同一个字段,操作员会以为改的是这一行。

加了两列,都是防止聚合后丢信息:

「SKU 数」—— 只显示颜色数和尺码数会严重误导。实测 602 个商品(11.6%)
的颜色数×尺码数大于实际 SKU 数,最悬殊的 26886533818 是 15×5=75 但
实际只有 28 个。蝦皮报表只含有销售成绩的 SKU,数据本来就不全。

「待补」—— 原来解析失败的行整行标黄,聚合成数量后这个信号会丢。
23 条失败分布在 6 个商品里,其中 4 个是「部分失败」:数字看着正常,
坏数据被吃掉,永远没人去补。现在待补>0 整行标黄。

颜色数/尺码数只统计 parse_ok=1,否则空值会被算进 DISTINCT 多出
一个「空颜色」。

弹窗只读,待补的行显示 spec_raw 原文——不显示的话人工不知道该填什么,
保留原文这个设计就白做了。复用 #18 的弹窗机制,app.js 未改动。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-08 11:12:25 +08:00
co-authored by Claude Opus 5
parent d199867101
commit 64d5e74d0d
8 changed files with 658 additions and 81 deletions
+31 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,12 +16,39 @@ import (
// ShopeeList 渲染蝦皮数据列表页。 // ShopeeList 渲染蝦皮数据列表页。
// //
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。 // **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
// 列定义见 docs/admin/05-ui-specification.md §4.2。 // 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
func (h *Handler) ShopeeList(c *gin.Context) { func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil) h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil)
} }
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
//
// 复用 #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 == "" {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetShopeeProductDetail(h.db, goodsID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个商品不存在,请刷新页面。")
return
}
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{"D": detail})
}
// ShopeeImport 处理 Excel 上传导入。 // ShopeeImport 处理 Excel 上传导入。
// //
// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3): // 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3):
@@ -109,7 +137,7 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。 // renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) { func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
result, err := service.ListShopeeSKUs(h.db, keyword) result, err := service.ListShopeeProducts(h.db, keyword)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
+1
View File
@@ -44,6 +44,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
// 1. 蝦皮数据 // 1. 蝦皮数据
pages.GET("/shopee", h.ShopeeList) pages.GET("/shopee", h.ShopeeList)
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
pages.POST("/shopee/import", h.ShopeeImport) pages.POST("/shopee/import", h.ShopeeImport)
pages.POST("/shopee/save", h.ShopeeSave) pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/delete", h.ShopeeDelete) pages.POST("/shopee/delete", h.ShopeeDelete)
+128 -32
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
@@ -77,65 +78,78 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
return nil return nil
} }
// ShopeeSKURow 是列表页要的一行:SKU 本身,联查出商品标题和采集状态。 // ShopeeProductRow 是列表页要的一行:**商品级**聚合数据,联查出采集状态。
//
// `pdd_goods_url` / `pdd_goods_id` 挂在 shopee_products 上,是商品级字段——
// 一行对应一个商品,不是一个 SKU,见工单 #41。
//
// 颜色数 / 尺码数只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41):
// 把 parse_ok = 0 的空颜色/空尺码算进 DISTINCT 会多出一个"空值"选项。
// SKUCount 是这个商品报表里出现过的全部 SKU 数(不管解析成功与否)——
// 蝦皮报表只含有销售成绩的 SKU,这个数天然会小于"颜色数 × 尺码数",
// 加这一列就是为了防止操作员把两者乘出来的数当成实际规格数(见 #41)。
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补",> 0 时列表整行标黄。
// //
// 采集状态挂在 pdd_products 上,不在蝦皮这边,见 // 采集状态挂在 pdd_products 上,不在蝦皮这边,见
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为 // docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。 // NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
type ShopeeSKURow struct { type ShopeeProductRow struct {
model.ShopeeSKU model.ShopeeProduct
ProductTitle string ColorCount int
PddGoodsURL string SizeCount int
PddGoodsID string SKUCount int
PendingCount int
CollectStatus sql.NullString CollectStatus sql.NullString
CollectMsg sql.NullString CollectMsg sql.NullString
} }
// ListShopeeSKUs 按蝦皮商品 ID 关键字查 SKU 列表(联查商品标题和采集状态)。 // ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
// keyword 为空表示不筛选。 // keyword 为空表示不筛选。
func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) { func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
sqlText := ` sqlText := `
SELECT sk.sku_id, sk.goods_id, sk.spec_raw, sk.color, sk.size, sk.advice, SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
sk.parse_ok, sk.sku_code, sk.is_manual, sk.created_at, sk.updated_at, sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
sp.title, sp.pdd_goods_url, sp.pdd_goods_id, COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
COUNT(sk.sku_id) AS sku_count,
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
pp.collect_status, pp.collect_msg pp.collect_status, pp.collect_msg
FROM shopee_skus sk FROM shopee_products sp
JOIN shopee_products sp ON sp.goods_id = sk.goods_id LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
LEFT JOIN pdd_products pp LEFT JOIN pdd_products pp
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
var args []any var args []any
if keyword = strings.TrimSpace(keyword); keyword != "" { if keyword = strings.TrimSpace(keyword); keyword != "" {
sqlText += ` WHERE sk.goods_id LIKE ? ESCAPE '\'` like := "%" + escapeLike(keyword) + "%"
args = append(args, "%"+escapeLike(keyword)+"%") sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'`
args = append(args, like, like)
} }
sqlText += ` ORDER BY sk.goods_id, sk.sku_id` sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id`
rows, err := q.Query(sqlText, args...) rows, err := q.Query(sqlText, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("查询蝦皮 SKU 列表失败: %w", err) return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
} }
defer rows.Close() defer rows.Close()
var list []ShopeeSKURow var list []ShopeeProductRow
for rows.Next() { for rows.Next() {
var r ShopeeSKURow var r ShopeeProductRow
var color, size, advice, skuCode sql.NullString var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
var parseOK, isManual int
var pddGoodsURL, pddGoodsID sql.NullString
if err := rows.Scan( if err := rows.Scan(
&r.SKUID, &r.GoodsID, &r.SpecRaw, &color, &size, &advice, &r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&parseOK, &skuCode, &isManual, &r.CreatedAt, &r.UpdatedAt, &pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
&r.ProductTitle, &pddGoodsURL, &pddGoodsID, &r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
&r.CollectStatus, &r.CollectMsg, &r.CollectStatus, &r.CollectMsg,
); err != nil { ); err != nil {
return nil, fmt.Errorf("读取蝦皮 SKU 列表失败: %w", err) return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
} }
r.Color = color.String r.Title = title.String
r.Size = size.String r.ShopeeStatus = shopeeStatus.String
r.Advice = advice.String r.MainSKUCode = mainSKUCode.String
r.SKUCode = skuCode.String
r.ParseOK = parseOK != 0
r.IsManual = isManual != 0
r.PddGoodsURL = pddGoodsURL.String r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String r.PddGoodsID = pddGoodsID.String
list = append(list, r) list = append(list, r)
@@ -143,6 +157,88 @@ func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
return list, rows.Err() return list, rows.Err()
} }
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
var p model.ShopeeProduct
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
err := q.QueryRow(`
SELECT goods_id, title, shopee_status, main_sku_code,
pdd_goods_url, pdd_goods_id, created_at, updated_at
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err)
}
p.Title = title.String
p.ShopeeStatus = shopeeStatus.String
p.MainSKUCode = mainSKUCode.String
p.PddGoodsURL = pddGoodsURL.String
p.PddGoodsID = pddGoodsID.String
return &p, nil
}
// GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。
// pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时,
// 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。
func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullString, err error) {
if pddGoodsID == "" {
return status, msg, nil
}
err = q.QueryRow(`
SELECT collect_status, collect_msg FROM pdd_products
WHERE goods_id = ? AND deleted_at IS NULL`, pddGoodsID).Scan(&status, &msg)
if errors.Is(err, sql.ErrNoRows) {
return sql.NullString{}, sql.NullString{}, nil
}
if err != nil {
return status, msg, fmt.Errorf("查询 PDD 商品 %s 采集状态失败: %w", pddGoodsID, err)
}
return status, msg, nil
}
// ListShopeeSKUsByGoodsID 查一个商品的完整规格表,**含解析失败的行**。
//
// 弹窗要显示全部规格(不管待补与否),所以这里不按 parse_ok 过滤,
// 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
rows, err := q.Query(`
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at
FROM shopee_skus
WHERE goods_id = ?
ORDER BY sku_id`, goodsID)
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品 %s 的规格失败: %w", goodsID, err)
}
defer rows.Close()
var list []model.ShopeeSKU
for rows.Next() {
var sk model.ShopeeSKU
var color, size, advice, skuCode sql.NullString
var parseOK, isManual int
if err := rows.Scan(
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err)
}
sk.Color = color.String
sk.Size = size.String
sk.Advice = advice.String
sk.SKUCode = skuCode.String
sk.ParseOK = parseOK != 0
sk.IsManual = isManual != 0
list = append(list, sk)
}
return list, rows.Err()
}
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。 // CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
func CountShopeeProducts(q Execer) (int, error) { func CountShopeeProducts(q Execer) (int, error) {
var n int var n int
+129 -28
View File
@@ -10,19 +10,26 @@ import (
"cmautobuy/admin/repository" "cmautobuy/admin/repository"
) )
// ShopeeSKUView 是列表页一行要显示的全部内容,全部已经是字符串, // ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串,
// 模板里不做判断和格式化。 // 模板里不做判断和格式化。
type ShopeeSKUView struct { //
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string GoodsID string
Title string Title string
SKUID string
Color string // ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
Size string ColorCount int
Advice string SizeCount int
// ParseFailed 为 true 时整行标黄,提示需要人工补颜色/尺码/建议, // SKUCount 是这个商品报表里实际出现过的 SKU 数(不管解析成功与否)。
// 见 docs/admin/05-ui-specification.md §4.2。 // `[必须]` 必须单独展示:蝦皮报表只含有销售成绩的 SKU,
ParseFailed bool // 颜色数 × 尺码数经常大于 SKUCount,只显示前两者会严重误导操作员,
// 让人以为要匹配的规格比实际多得多(实测最悬殊相差 2.7 倍,见 #41)。
SKUCount int
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补"。
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
PendingCount int
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红—— // PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
// 这是最需要操作员注意的状态,见 §4.2。 // 这是最需要操作员注意的状态,见 §4.2。
@@ -30,26 +37,25 @@ type ShopeeSKUView struct {
PddMissing bool PddMissing bool
StatusText string StatusText string
IsManual bool
UpdatedAt string UpdatedAt string
} }
// ShopeeListResult 是列表页要的全部数据。 // ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct { type ShopeeListResult struct {
Rows []ShopeeSKUView Rows []ShopeeProductView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过" Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
IsFiltered bool IsFiltered bool
} }
// ListShopeeSKUs 查蝦皮 SKU 列表并把每一行翻成界面文字。 // ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
// //
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段, // 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1: // 见 docs/admin/01-requirements.md §6.1:
// //
// shopee_products.pdd_goods_id 为空 -> "未填链接" // shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败 // pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) { func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeSKUs(db, keyword) rows, err := repository.ListShopeeProducts(db, keyword)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,27 +65,20 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
} }
result := &ShopeeListResult{ result := &ShopeeListResult{
Rows: make([]ShopeeSKUView, 0, len(rows)), Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total, Total: total,
IsFiltered: keyword != "", IsFiltered: keyword != "",
} }
for _, r := range rows { for _, r := range rows {
v := ShopeeSKUView{ v := ShopeeProductView{
GoodsID: r.GoodsID, GoodsID: r.GoodsID,
Title: r.ProductTitle, Title: r.Title,
SKUID: r.SKUID, ColorCount: r.ColorCount,
IsManual: r.IsManual, SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
UpdatedAt: formatLocalTime(r.UpdatedAt), UpdatedAt: formatLocalTime(r.UpdatedAt),
} }
if r.ParseOK {
v.Color, v.Size, v.Advice = r.Color, r.Size, r.Advice
if v.Advice == "" {
v.Advice = placeholder
}
} else {
v.Color, v.Size, v.Advice = placeholder, placeholder, placeholder
v.ParseFailed = true
}
if r.PddGoodsID == "" { if r.PddGoodsID == "" {
v.PddMissing = true v.PddMissing = true
@@ -100,3 +99,105 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
} }
return result, nil return result, nil
} }
// ---------- 弹窗 ----------
// ShopeeSpecView 是弹窗规格表的一行。
type ShopeeSpecView struct {
SKUID string
Color string
Size string
Advice string
// StatusText 是"✓"或"待补",`[必须]` 要有文字,不能只靠颜色区分(见 #41)。
StatusText string
// Pending 为 true 时模板整行标黄,并在下面加一行显示 SpecRaw 原文。
Pending bool
// SpecRaw 只在 Pending 为 true 时才有意义。
// `[必须]` 待补的行必须显示规格原文——不显示的话人工不知道该填什么,
// 这正是保留 spec_raw 这个设计的全部意义,见 #41。
SpecRaw string
}
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
//
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
// ShopeeSave 仍是 501,见 #41。
type ShopeeProductDetail struct {
GoodsID string
Title string
ShopeeStatus string
MainSKUCode string
PddURL string
PddMissing bool
StatusText string
SKUs []ShopeeSpecView
PendingCount int
}
// GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。
// 商品不存在返回 (nil, nil)。
func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, error) {
p, err := repository.GetShopeeProductByGoodsID(db, goodsID)
if err != nil || p == nil {
return nil, err
}
d := &ShopeeProductDetail{
GoodsID: p.GoodsID,
Title: p.Title,
ShopeeStatus: p.ShopeeStatus,
MainSKUCode: p.MainSKUCode,
}
if d.Title == "" {
d.Title = placeholder
}
if d.ShopeeStatus == "" {
d.ShopeeStatus = placeholder
}
if d.MainSKUCode == "" {
d.MainSKUCode = placeholder
}
if p.PddGoodsID == "" {
d.PddMissing = true
d.PddURL = "未填写"
d.StatusText = "未填链接"
} else {
d.PddURL = p.PddGoodsURL
status, _, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
if err != nil {
return nil, err
}
if status.Valid {
d.StatusText = collectStatusText(model.CollectStatus(status.String))
} else {
d.StatusText = "PDD 商品缺失"
}
}
skus, err := repository.ListShopeeSKUsByGoodsID(db, goodsID)
if err != nil {
return nil, err
}
d.SKUs = make([]ShopeeSpecView, 0, len(skus))
for _, sk := range skus {
row := ShopeeSpecView{SKUID: sk.SKUID}
if sk.ParseOK {
row.Color, row.Size, row.Advice = sk.Color, sk.Size, sk.Advice
if row.Advice == "" {
row.Advice = placeholder
}
row.StatusText = "✓"
} else {
row.Color, row.Size, row.Advice = placeholder, placeholder, placeholder
row.StatusText = "待补"
row.Pending = true
row.SpecRaw = sk.SpecRaw
d.PendingCount++
}
d.SKUs = append(d.SKUs, row)
}
return d, nil
}
+252
View File
@@ -0,0 +1,252 @@
package service
import (
"database/sql"
"testing"
"cmautobuy/admin/repository"
)
// seedShopeeProduct 建一个蝦皮商品,goods_id / title 之外不填别的。
func seedShopeeProduct(t *testing.T, db *sql.DB, goodsID, title string) {
t.Helper()
if err := repository.UpsertShopeeProduct(db, goodsID, title, "正常", "货号"+goodsID); err != nil {
t.Fatalf("建蝦皮商品 %s 失败: %v", goodsID, err)
}
}
// seedShopeeSKU 建一条蝦皮 SKU。specRaw 只在 parseOK=false 时用于校验回显。
func seedShopeeSKU(t *testing.T, db *sql.DB, skuID, goodsID, specRaw, color, size, advice string, parseOK bool) {
t.Helper()
if err := repository.UpsertShopeeSKU(db, skuID, goodsID, specRaw, color, size, advice, parseOK, "sku-"+skuID); err != nil {
t.Fatalf("建蝦皮 SKU %s 失败: %v", skuID, err)
}
}
// setShopeePddLink 直接写 shopee_products.pdd_goods_url / pdd_goods_id,
// 模拟"已经填过 PDD 链接"。ShopeeSave 本工单不实现,测试直接写库代替。
func setShopeePddLink(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddURL string) {
t.Helper()
_, err := db.Exec(
`UPDATE shopee_products SET pdd_goods_id = ?, pdd_goods_url = ? WHERE goods_id = ?`,
pddGoodsID, pddURL, goodsID)
if err != nil {
t.Fatalf("写蝦皮商品 %s 的 PDD 链接失败: %v", goodsID, err)
}
}
// ── 列表:商品级聚合 ──────────────────────────────────
func TestListShopeeProducts_商品级一行(t *testing.T) {
db := newTestDB(t)
// 商品 24420648774 在真实样本里有 62 个 SKU,这里用 3 个模拟同样的
// "一个商品多个 SKU 但只应该出现一行"的场景,见工单 #41。
seedShopeeProduct(t, db, "24420648774", "吊帶背心")
seedShopeeSKU(t, db, "sku-1", "24420648774", "", "黑色", "M", "40-50公斤", true)
seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true)
result, err := ListShopeeProducts(db, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 {
t.Fatalf("行数 = %d,想要 1(商品级一行,不是 SKU 级)", len(result.Rows))
}
row := result.Rows[0]
if row.SKUCount != 3 {
t.Errorf("SKUCount = %d,想要 3", row.SKUCount)
}
if row.ColorCount != 2 {
t.Errorf("ColorCount = %d,想要 2(黑/白)", row.ColorCount)
}
if row.SizeCount != 2 {
t.Errorf("SizeCount = %d,想要 2(M/L)", row.SizeCount)
}
if row.PendingCount != 0 {
t.Errorf("PendingCount = %d,想要 0", row.PendingCount)
}
}
func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) {
db := newTestDB(t)
// 模拟工单里 26886533818 的情形:颜色数 × 尺码数会大于实际 SKU 数,
// 而且有一部分 SKU 解析失败——它们不能被算进颜色/尺码的 DISTINCT。
seedShopeeProduct(t, db, "26886533818", "測試商品")
seedShopeeSKU(t, db, "s1", "26886533818", "", "咖啡色", "2XL", "", true)
seedShopeeSKU(t, db, "s2", "26886533818", "", "咖啡色", "3XL", "", true)
seedShopeeSKU(t, db, "s3", "26886533818", "", "紅色", "M", "", true)
// 解析失败的两条:spec_raw 保留,color/size 为空,不该被 DISTINCT 算进去。
seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false)
result, err := ListShopeeProducts(db, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 {
t.Fatalf("行数 = %d,想要 1", len(result.Rows))
}
row := result.Rows[0]
if row.SKUCount != 5 {
t.Errorf("SKUCount = %d,想要 5(含 2 条解析失败的)", row.SKUCount)
}
if row.ColorCount != 2 {
t.Errorf("ColorCount = %d,想要 2(咖啡色/紅色,不含解析失败的空颜色)", row.ColorCount)
}
if row.SizeCount != 3 {
t.Errorf("SizeCount = %d,想要 3(2XL/3XL/M,不含解析失败的空尺码)", row.SizeCount)
}
if row.PendingCount != 2 {
t.Errorf("PendingCount = %d,想要 2", row.PendingCount)
}
}
func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
result, err := ListShopeeProducts(db, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Rows[0].PendingCount != 1 {
t.Fatalf("PendingCount = %d,想要 1", result.Rows[0].PendingCount)
}
// 整行标黄由模板按 PendingCount > 0 判断,这里只验证数据源正确。
}
func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
result, err := ListShopeeProducts(db, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
row := result.Rows[0]
if !row.PddMissing || row.PddURL != "未填写" {
t.Errorf("PddMissing/PddURL = %v/%q,想要 true/\"未填写\"", row.PddMissing, row.PddURL)
}
if row.StatusText != "未填链接" {
t.Errorf("StatusText = %q,想要 \"未填链接\"", row.StatusText)
}
}
func TestListShopeeProducts_采集状态联查(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
if _, err := repository.EnsurePddProduct(db, "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001"); err != nil {
t.Fatalf("建 PDD 商品失败: %v", err)
}
if err := repository.SetCollectResult(db, "9001", "PDD标题", "店铺", "{}"); err != nil {
t.Fatalf("设置采集结果失败: %v", err)
}
setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
result, err := ListShopeeProducts(db, "")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
row := result.Rows[0]
if row.PddMissing {
t.Error("已填链接,PddMissing 应为 false")
}
if row.StatusText != "已采集" {
t.Errorf("StatusText = %q,想要 \"已采集\"", row.StatusText)
}
}
func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺码(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "吊帶背心")
seedShopeeProduct(t, db, "1002", "短袖T恤")
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
// 按商品 ID 匹配
result, err := ListShopeeProducts(db, "1001")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("按商品 ID 搜索结果不对: %+v", result.Rows)
}
// 按商品名称匹配
result, err = ListShopeeProducts(db, "背心")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("按商品名称搜索结果不对: %+v", result.Rows)
}
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
result, err = ListShopeeProducts(db, "黑色")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 0 {
t.Fatalf("关键字不应匹配颜色,得到 %+v", result.Rows)
}
}
// ── 弹窗详情 ──────────────────────────────────
func TestGetShopeeProductDetail_待补行显示原文(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "40-50公斤", true)
seedShopeeSKU(t, db, "s2", "1001", "紅色,3XL建議80-90公斤】", "", "", "", false)
detail, err := GetShopeeProductDetail(db, "1001")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if detail == nil {
t.Fatal("详情为 nil")
}
if len(detail.SKUs) != 2 {
t.Fatalf("SKUs 数量 = %d,想要 2(含解析失败的)", len(detail.SKUs))
}
if detail.PendingCount != 1 {
t.Errorf("PendingCount = %d,想要 1", detail.PendingCount)
}
var pending *ShopeeSpecView
for i := range detail.SKUs {
if detail.SKUs[i].Pending {
pending = &detail.SKUs[i]
}
}
if pending == nil {
t.Fatal("没有找到待补的那一行")
}
if pending.SpecRaw != "紅色,3XL建議80-90公斤】" {
t.Errorf("SpecRaw = %q,想要显示原文", pending.SpecRaw)
}
if pending.StatusText != "待补" {
t.Errorf("StatusText = %q,想要 \"待补\"", pending.StatusText)
}
for _, s := range detail.SKUs {
if !s.Pending && s.StatusText != "✓" {
t.Errorf("解析成功的行 StatusText = %q,想要 \"✓\"", s.StatusText)
}
}
}
func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
db := newTestDB(t)
detail, err := GetShopeeProductDetail(db, "not-exist")
if err != nil {
t.Fatalf("不应该报错: %v", err)
}
if detail != nil {
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
}
}
+8
View File
@@ -151,6 +151,14 @@ tr.empty small { color: #aaa; }
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* 蝦皮数据页商品名称列收窄到 30%,配合 .truncate 一起用(见工单 #41)。
用独立类设 max-width,不用 flex——.search-narrow 那套是给工具条用的,
表格单元格不是 flex 布局,flex 在这里不生效。放在 .truncate 之后,
同优先级下按 CSS 层叠顺序覆盖 .truncate 的 280px。 */
.col-title {
max-width: 30%;
}
/* ── 弹窗 ─────────────────────────────── */ /* ── 弹窗 ─────────────────────────────── */
/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */ /* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */
.modal-backdrop[hidden] { display: none; } .modal-backdrop[hidden] { display: none; }
+70
View File
@@ -0,0 +1,70 @@
{{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">
<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>
</dl>
<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>
<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}}
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
<div class="modal-foot">
<button type="button" data-modal-close>关闭</button>
</div>
{{end}}
{{end}}
+37 -16
View File
@@ -28,6 +28,8 @@
</div> </div>
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}} {{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
{{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。
双击行打开详情弹窗,见 static/js/app.js。 */}}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@@ -37,7 +39,14 @@
<th>商品名称</th> <th>商品名称</th>
<th>颜色</th> <th>颜色</th>
<th>尺码</th> <th>尺码</th>
<th>建议</th> {{/* SKU 数是这个商品报表里实际出现过的规格条数。
`[必须]` 必须单独显示——颜色数 × 尺码数经常大于它(蝦皮报表只含
有销售成绩的 SKU),只看前两列会让人误以为要匹配的规格更多,见 #41。 */}}
<th>SKU</th>
{{/* `[必须]` 待补 > 0 说明该商品有 SKU 解析失败,整行标黄(.row-warn)。
聚合成商品级之后,"部分失败"的行光看数字是正常的,
不加这一列坏数据就会被悄悄吃掉,见 #41。 */}}
<th>待补</th>
<th>PDD 链接</th> <th>PDD 链接</th>
<th>采集状态</th> <th>采集状态</th>
<th>更新时间</th> <th>更新时间</th>
@@ -45,22 +54,25 @@
</thead> </thead>
<tbody> <tbody>
{{range .Rows}} {{range .Rows}}
<tr{{if .ParseFailed}} class="row-warn"{{end}} title="SKU {{.SKUID}}"> <tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td> <td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td>
<td>{{.GoodsID}}</td> <td>{{.GoodsID}}</td>
<td title="{{.Title}}">{{.Title}}{{if .IsManual}} <small>(人工新增)</small>{{end}}</td> {{/* 商品名称列收窄到 30%,用独立的 .col-title 类设 max-width,
<td>{{.Color}}</td> 不用 flex——表格单元格和工具条的 .search-narrow 那套不一样,见 #41。 */}}
<td>{{.Size}}</td> <td class="truncate col-title" title="{{.Title}}">{{.Title}}</td>
<td>{{.Advice}}</td> <td>{{.ColorCount}}</td>
<td>{{.SizeCount}}</td>
<td>{{.SKUCount}}</td>
<td>{{.PendingCount}}</td>
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td> <td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
<td>{{.StatusText}}</td> <td>{{.StatusText}}</td>
<td>{{.UpdatedAt}}</td> <td>{{.UpdatedAt}}</td>
</tr> </tr>
{{else}} {{else}}
<tr class="empty"> <tr class="empty">
<td colspan="9"> <td colspan="10">
{{if .IsFiltered}} {{if .IsFiltered}}
没有匹配的数据,换个商品 ID 试试。 没有匹配的数据,换个商品 ID 或商品名称试试。
{{else}} {{else}}
还没有数据,点左上角「导入 Excel」开始。<br> 还没有数据,点左上角「导入 Excel」开始。<br>
<small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small> <small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small>
@@ -72,6 +84,10 @@
</table> </table>
</div> </div>
<p class="hint">
双击任意一行可以查看这个商品的完整规格表。
</p>
{{/* ── 导入失败行:全部列出来,不折叠、不只显示条数 ──── */}} {{/* ── 导入失败行:全部列出来,不折叠、不只显示条数 ──── */}}
{{if .Failures}} {{if .Failures}}
<div class="table-wrap"> <div class="table-wrap">
@@ -97,15 +113,20 @@
</div> </div>
{{end}} {{end}}
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html {{/* ── 详情弹窗的壳子 ───────────────────────────
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。 里面的内容双击行时由 /shopee/detail 返回,前端只负责放进来和显示,
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行, 复用 #18 的机制(data-detail-url / data-detail-slot / data-detail-id),
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事, app.js 不需要改,见工单 #41。
还会建出 N 个重复任务。
PDD 商品页(templates/pdd/)也能录入链接和发起采集,两处并存; 这个弹窗**只读**:商品信息 + 完整规格表,没有保存按钮。
本页这个还是骨架(点了返回 501)。合并与否由 从蝦皮商品出发录入 PDD 链接、发起采集的入口(含 ShopeeSave/
「蝦皮↔PDD 关联入口」工单决定,不要在本页单独改。 */}} 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-body">正在加载…</div>
</div>
</div>
{{template "footer" .}} {{template "footer" .}}
{{end}} {{end}}