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
+131 -30
View File
@@ -10,19 +10,26 @@ import (
"cmautobuy/admin/repository"
)
// ShopeeSKUView 是列表页一行要显示的全部内容,全部已经是字符串,
// ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串,
// 模板里不做判断和格式化。
type ShopeeSKUView struct {
//
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
type ShopeeProductView struct {
GoodsID string
Title string
SKUID string
Color string
Size string
Advice string
// ParseFailed 为 true 时整行标黄,提示需要人工补颜色/尺码/建议,
// 见 docs/admin/05-ui-specification.md §4.2。
ParseFailed bool
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
ColorCount int
SizeCount int
// SKUCount 是这个商品报表里实际出现过的 SKU 数(不管解析成功与否)。
// `[必须]` 必须单独展示:蝦皮报表只含有销售成绩的 SKU,
// 颜色数 × 尺码数经常大于 SKUCount,只显示前两者会严重误导操作员,
// 让人以为要匹配的规格比实际多得多(实测最悬殊相差 2.7 倍,见 #41)。
SKUCount int
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补"。
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
PendingCount int
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
// 这是最需要操作员注意的状态,见 §4.2。
@@ -30,26 +37,25 @@ type ShopeeSKUView struct {
PddMissing bool
StatusText string
IsManual bool
UpdatedAt string
}
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeSKUView
Rows []ShopeeProductView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
IsFiltered bool
}
// ListShopeeSKUs 查蝦皮 SKU 列表并把每一行翻成界面文字。
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
//
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1:
//
// shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeSKUs(db, keyword)
func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeProducts(db, keyword)
if err != nil {
return nil, err
}
@@ -59,26 +65,19 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
}
result := &ShopeeListResult{
Rows: make([]ShopeeSKUView, 0, len(rows)),
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
IsFiltered: keyword != "",
}
for _, r := range rows {
v := ShopeeSKUView{
GoodsID: r.GoodsID,
Title: r.ProductTitle,
SKUID: r.SKUID,
IsManual: r.IsManual,
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
v := ShopeeProductView{
GoodsID: r.GoodsID,
Title: r.Title,
ColorCount: r.ColorCount,
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
UpdatedAt: formatLocalTime(r.UpdatedAt),
}
if r.PddGoodsID == "" {
@@ -100,3 +99,105 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
}
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)
}
}