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
}