Files
cmautobuy/admin/repository/shopee.go
T
chengmaandClaude Opus 5 e29d6683ad feat: 蝦皮 Excel 报表导入 (#38)
蝦皮数据模块此前整个是骨架,四个入口全返回 501,excelize 连依赖都没装。
本工单做导入和列表,Save/Delete/Collect 保持 501。

真实样本实测:5195 个商品 / 6092 个 SKU,23 行解析失败。

upsert 白名单式,pdd_goods_url / pdd_goods_id 既不在 INSERT 列清单里
也不在 DO UPDATE SET 里。报表没有这两列,写进去就是写空值——操作员
攒几周的 PDD 链接会被一次导入洗光,而且不报错,等到建采购任务才发现。
有独立测试守着:往 DO UPDATE SET 里加回这一行,测试立刻变红。

按 sheet 名字取「最佳表現商品」,不用第 0 个。工作簿有 5 个 sheet,
另外 4 个是广告报表,连 商品規格ID 列都没有;蝦皮调顺序时按下标
会静默导入一张完全不相干的表。

按列名找索引。40 列里 32 列是统计指标,蝦皮加一列所有列号就错位,
而且不报错——会把「點擊率」当成「商品規格」存进去。

规格解析三种格式:含【】53.4%、逗号+空格 26.4%、只有逗号 20.2%,
只认【】会漏掉 46.6%。括号不配对(21 行)和右半整个被【】包住(2 行)
一律判整体失败,不逐段硬凑——错法不统一,针对每种错法写规则等于在猜,
而且没有人工核对过的正确答案可验证。失败时 color/size/advice 必须全空,
原文存进 spec_raw 交人工补。

不写 DELETE + INSERT 的全量替换,将来手动新增的 SKU 会被删掉。

excelize v2.9.1 的 go 指令正好是 1.23.0,与项目固定版本一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:57:12 +08:00

154 lines
5.5 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"
"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
}
// ShopeeSKURow 是列表页要的一行:SKU 本身,联查出商品标题和采集状态。
//
// 采集状态挂在 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
CollectStatus sql.NullString
CollectMsg sql.NullString
}
// ListShopeeSKUs 按蝦皮商品 ID 关键字查 SKU 列表(联查商品标题和采集状态)。
// keyword 为空表示不筛选。
func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, 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,
pp.collect_status, pp.collect_msg
FROM shopee_skus sk
JOIN shopee_products sp ON sp.goods_id = sk.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)+"%")
}
sqlText += ` ORDER BY sk.goods_id, sk.sku_id`
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询蝦皮 SKU 列表失败: %w", err)
}
defer rows.Close()
var list []ShopeeSKURow
for rows.Next() {
var r ShopeeSKURow
var color, size, advice, skuCode sql.NullString
var parseOK, isManual int
var 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.CollectStatus, &r.CollectMsg,
); err != nil {
return nil, fmt.Errorf("读取蝦皮 SKU 列表失败: %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.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
list = append(list, r)
}
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
}