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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user