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:
chengma
2026-08-08 09:57:12 +08:00
co-authored by Claude Opus 5
parent 18770f25e1
commit e29d6683ad
10 changed files with 1211 additions and 91 deletions
+102
View File
@@ -0,0 +1,102 @@
// 蝦皮数据列表页的查询和界面文字组装。
//
// 分成单独文件是因为它和 shopee_import.go 职责不同:这里只读,不写库。
package service
import (
"database/sql"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ShopeeSKUView 是列表页一行要显示的全部内容,全部已经是字符串,
// 模板里不做判断和格式化。
type ShopeeSKUView 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
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
// 这是最需要操作员注意的状态,见 §4.2。
PddURL string
PddMissing bool
StatusText string
IsManual bool
UpdatedAt string
}
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeSKUView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
IsFiltered bool
}
// ListShopeeSKUs 查蝦皮 SKU 列表并把每一行翻成界面文字。
//
// 采集状态来自 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)
if err != nil {
return nil, err
}
total, err := repository.CountShopeeProducts(db)
if err != nil {
return nil, err
}
result := &ShopeeListResult{
Rows: make([]ShopeeSKUView, 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
}
if r.PddGoodsID == "" {
v.PddMissing = true
v.PddURL = "未填写"
v.StatusText = "未填链接"
} else {
v.PddURL = r.PddGoodsURL
if r.CollectStatus.Valid {
v.StatusText = collectStatusText(model.CollectStatus(r.CollectStatus.String))
} else {
// pdd_goods_id 有值但 pdd_products 里查不到对应行
// (比如那条 PDD 商品被软删除了),如实说明,不要装作"未采集"。
v.StatusText = "PDD 商品缺失"
}
}
result.Rows = append(result.Rows, v)
}
return result, nil
}