Files
cmautobuy/admin/repository/shopee.go
T
chengmaandClaude Opus 5 64d5e74d0d 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>
2026-08-08 11:12:25 +08:00

250 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"database/sql"
"errors"
"fmt"
"strings"
"cmautobuy/admin/model"
)
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
//
// `[必须]` DO UPDATE SET 里绝不允许出现 pdd_goods_url / pdd_goods_id。
// 蝦皮报表里没有这两列,写进去就是写空值——操作员可能攒了几周的
// PDD 链接,导入一次就被静默洗掉,而且不报错,等到建采购任务时
// 才会发现,那时已经找不回来了。见工单 #38、admin/AGENTS.md
// 「Excel 导入只做 upsert,绝不允许先清空再导入」。
func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode string) error {
if goodsID == "" {
return fmt.Errorf("goods_id 不能为空")
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(goods_id) DO UPDATE SET
title = excluded.title,
shopee_status = excluded.shopee_status,
main_sku_code = excluded.main_sku_code,
updated_at = excluded.updated_at`,
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
// 已存在时它们完全不受这条语句影响。
goodsID, title, shopeeStatus, mainSKUCode, now, now)
if err != nil {
return fmt.Errorf("写入蝦皮商品 %s 失败: %w", goodsID, err)
}
return nil
}
// UpsertShopeeSKU 写入或更新一条蝦皮 SKU 行。
//
// `[必须]` is_manual 不在 DO UPDATE SET 里,已存在的行导入时保持原值不变。
// 人工新增的 SKU(is_manual=1)就是靠这个不被导入覆盖成 0——
// 本工单不做手动新增功能,但这个口子现在就要留好。
func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode string) error {
if skuID == "" {
return fmt.Errorf("sku_id 不能为空")
}
if goodsID == "" {
return fmt.Errorf("goods_id 不能为空")
}
parseOKInt := 0
if parseOK {
parseOKInt = 1
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO shopee_skus
(sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
is_manual, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(sku_id) DO UPDATE SET
goods_id = excluded.goods_id,
spec_raw = excluded.spec_raw,
color = excluded.color,
size = excluded.size,
advice = excluded.advice,
parse_ok = excluded.parse_ok,
sku_code = excluded.sku_code,
updated_at = excluded.updated_at`,
// is_manual 不在上面的 SET 列表里,SQLite 对没提到的列保持原值不变。
skuID, goodsID, specRaw, color, size, advice, parseOKInt, skuCode, now, now)
if err != nil {
return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err)
}
return nil
}
// 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 ShopeeProductRow struct {
model.ShopeeProduct
ColorCount int
SizeCount int
SKUCount int
PendingCount int
CollectStatus sql.NullString
CollectMsg sql.NullString
}
// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
// keyword 为空表示不筛选。
func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
sqlText := `
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_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 != "" {
like := "%" + escapeLike(keyword) + "%"
sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'`
args = append(args, like, like)
}
sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id`
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
}
defer rows.Close()
var list []ShopeeProductRow
for rows.Next() {
var r ShopeeProductRow
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
if err := rows.Scan(
&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("读取蝦皮商品列表失败: %w", err)
}
r.Title = title.String
r.ShopeeStatus = shopeeStatus.String
r.MainSKUCode = mainSKUCode.String
r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
list = append(list, r)
}
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
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
}
return n, nil
}