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:
+128
-32
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -77,65 +78,78 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
|
||||
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 上,不在蝦皮这边,见
|
||||
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
|
||||
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
|
||||
type ShopeeSKURow struct {
|
||||
model.ShopeeSKU
|
||||
ProductTitle string
|
||||
PddGoodsURL string
|
||||
PddGoodsID string
|
||||
type ShopeeProductRow struct {
|
||||
model.ShopeeProduct
|
||||
ColorCount int
|
||||
SizeCount int
|
||||
SKUCount int
|
||||
PendingCount int
|
||||
CollectStatus sql.NullString
|
||||
CollectMsg sql.NullString
|
||||
}
|
||||
|
||||
// ListShopeeSKUs 按蝦皮商品 ID 关键字查 SKU 列表(联查商品标题和采集状态)。
|
||||
// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
|
||||
//
|
||||
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
|
||||
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
|
||||
// keyword 为空表示不筛选。
|
||||
func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
|
||||
func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
|
||||
sqlText := `
|
||||
SELECT sk.sku_id, sk.goods_id, sk.spec_raw, sk.color, sk.size, sk.advice,
|
||||
sk.parse_ok, sk.sku_code, sk.is_manual, sk.created_at, sk.updated_at,
|
||||
sp.title, sp.pdd_goods_url, sp.pdd_goods_id,
|
||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
|
||||
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
|
||||
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
|
||||
FROM shopee_skus sk
|
||||
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
||||
FROM shopee_products sp
|
||||
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
||||
LEFT JOIN pdd_products pp
|
||||
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
|
||||
var args []any
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
sqlText += ` WHERE sk.goods_id LIKE ? ESCAPE '\'`
|
||||
args = append(args, "%"+escapeLike(keyword)+"%")
|
||||
like := "%" + 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...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询蝦皮 SKU 列表失败: %w", err)
|
||||
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []ShopeeSKURow
|
||||
var list []ShopeeProductRow
|
||||
for rows.Next() {
|
||||
var r ShopeeSKURow
|
||||
var color, size, advice, skuCode sql.NullString
|
||||
var parseOK, isManual int
|
||||
var pddGoodsURL, pddGoodsID sql.NullString
|
||||
var r ShopeeProductRow
|
||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&r.SKUID, &r.GoodsID, &r.SpecRaw, &color, &size, &advice,
|
||||
&parseOK, &skuCode, &isManual, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ProductTitle, &pddGoodsURL, &pddGoodsID,
|
||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
||||
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||||
&r.CollectStatus, &r.CollectMsg,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取蝦皮 SKU 列表失败: %w", err)
|
||||
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
|
||||
}
|
||||
r.Color = color.String
|
||||
r.Size = size.String
|
||||
r.Advice = advice.String
|
||||
r.SKUCode = skuCode.String
|
||||
r.ParseOK = parseOK != 0
|
||||
r.IsManual = isManual != 0
|
||||
r.Title = title.String
|
||||
r.ShopeeStatus = shopeeStatus.String
|
||||
r.MainSKUCode = mainSKUCode.String
|
||||
r.PddGoodsURL = pddGoodsURL.String
|
||||
r.PddGoodsID = pddGoodsID.String
|
||||
list = append(list, r)
|
||||
@@ -143,6 +157,88 @@ func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
|
||||
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 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||
func CountShopeeProducts(q Execer) (int, error) {
|
||||
var n int
|
||||
|
||||
Reference in New Issue
Block a user