Files
cmautobuy/admin/service/shopee_import_test.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

382 lines
15 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 service
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"github.com/xuri/excelize/v2"
"cmautobuy/admin/model"
)
// ── ParseSpec ──────────────────────────────────────────
func TestParseSpec(t *testing.T) {
cases := []struct {
name string
raw string
wantColor string
wantSize string
wantAdvice string
wantOK bool
}{
{
name: "带【】的建议", raw: "黑色,M【建議40-50公斤】",
wantColor: "黑色", wantSize: "M", wantAdvice: "40-50公斤", wantOK: true,
},
{
name: "空格分隔的建议", raw: "卡其色拼黑色,L 建議50-57.5kg",
wantColor: "卡其色拼黑色", wantSize: "L", wantAdvice: "50-57.5kg", wantOK: true,
},
{
name: "只有颜色和尺码没有建议", raw: "黑色,M",
wantColor: "黑色", wantSize: "M", wantAdvice: "", wantOK: true,
},
{
name: "颜色自己也带【】", raw: "黑色 【夏裝單件T恤】,L 【57.5/70公斤】",
wantColor: "黑色 【夏裝單件T恤】", wantSize: "L", wantAdvice: "57.5/70公斤", wantOK: true,
},
{
// 真实样本里的脏数据:缺左括号。见 ParseSpec 注释里"21 行括号
// 不配对怎么处理"一节——整体判定失败,不猜。
name: "括号不配对_缺左括号", raw: "紅色,3XL建議80-90公斤】",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 真实样本里的另一种脏数据:缺右括号,而且用"寬鬆版"代替"建議"。
name: "括号不配对_缺右括号", raw: "粉色,3XL【寬鬆版 82.5-92.5kg",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "完全认不出的格式_没有逗号", raw: "莫名其妙的格式",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "两个逗号_超出已知范围", raw: "黑色,M,多余",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
name: "空字符串", raw: "",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// raw 本身非空(trim 前有内容),但 trim 后为空——用来跟"空字符串"
// 那条区分开:如果 trimmed=="" 分支直接把 raw 当返回值,
// 这条能抓到(raw!=""),"空字符串"那条抓不到(raw 本身就是 "")。
name: "只有空白字符", raw: " ",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 逗号右侧为空——只有颜色,没有尺码信息。
name: "逗号右侧为空", raw: "黑色,",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 逗号左侧为空——只有尺码,没有颜色信息。
name: "逗号左侧为空", raw: ",M",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
{
// 真实样本第 5454 行:右半整个被【】包住,括号外没有尺码文字,
// 提取后 size 为空。这是真实数据里 23 行失败中的 2 行之一,
// 直接用真实原文而不是自造用例,证明的是"这个真实格式我们
// 确实认不出来,而且认不出来时不猜",不只是代码逻辑自洽。
name: "右半整个被【】包住_size提取后为空", raw: "面膜安全褲3條膚色,【2xl 70-85公斤可穿】",
wantColor: "", wantSize: "", wantAdvice: "", wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
color, size, advice, ok := ParseSpec(tc.raw)
if ok != tc.wantOK {
t.Fatalf("ok = %v,想要 %v(raw=%q)", ok, tc.wantOK, tc.raw)
}
if !ok {
// [必须] ok=false 时前三个必须全为空,不许出现"猜出来的一半"。
if color != "" || size != "" || advice != "" {
t.Fatalf("ok=false 时 color/size/advice 必须全为空,得到 %q/%q/%q",
color, size, advice)
}
return
}
if color != tc.wantColor || size != tc.wantSize || advice != tc.wantAdvice {
t.Fatalf("got %q/%q/%q,want %q/%q/%q",
color, size, advice, tc.wantColor, tc.wantSize, tc.wantAdvice)
}
})
}
}
// ── 测试固件 ──────────────────────────────────────────
// shopeeHeader 是真实报表的表头,只留导入用得到的 8 个业务列——
// 和真实报表一样按名字定位,列的相对顺序特意打乱一下(把 8 列之外的
// 顺序无所谓,这里保持简单,只放业务列)。
var shopeeHeader = []string{
"商品ID", "商品名稱", "商品當前狀態", "商品規格ID",
"商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號",
}
// buildShopeeWorkbook 现造一个 xlsx 固件,覆盖工单要求的场景:
// 正常行、三种规格格式、括号不配对、以及无关的广告报表 sheet
// (用来验证"按名字取 sheet,不用下标 0")。
//
// [必须] 测试不依赖 raw_data/,固件必须是现造或者提交进 admin/testdata/
// 的小文件,见工单 #38。这里选择现造:文件是二进制,提交进仓库不好 review。
func buildShopeeWorkbook(t *testing.T, header []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defer f.Close()
// 默认 sheet 改名成一个无关的广告报表,模拟真实工作簿里
// "最佳表現商品"不是第 0 个 sheet 这种情况(虽然样本里恰好是,
// 但不能依赖这个巧合,见 ImportShopeeExcel 的实现和它的注释)。
f.SetSheetName("Sheet1", "追蹤商品廣告成效")
f.NewSheet("最佳表現商品")
if header != nil {
for col, name := range header {
cellRef, _ := excelize.CoordinatesToCellName(col+1, 1)
f.SetCellStr("最佳表現商品", cellRef, name)
}
}
for r, row := range rows {
for col, val := range row {
cellRef, _ := excelize.CoordinatesToCellName(col+1, r+2)
f.SetCellStr("最佳表現商品", cellRef, val)
}
}
path := filepath.Join(t.TempDir(), "shopee_test.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("保存测试固件失败: %v", err)
}
return path
}
// row 按 shopeeHeader 的列顺序拼一行,减少每个测试用例里的列对齐负担。
func row(goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode string) []string {
return []string{goodsID, title, status, specID, spec, specStatus, skuCode, mainSKUCode}
}
// ── ImportShopeeExcel ──────────────────────────────────
func TestImportShopeeExcel_基本导入(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
// 商品 A:汇总行 + 3 种规格格式的 SKU 行
row("1001", "测试商品A", "正常", "-", "-", "", "", "货号A"),
row("1001", "测试商品A", "正常", "9001", "黑色,M【建議40-50公斤】", "正常", "sku-9001", "货号A"),
row("1001", "测试商品A", "正常", "9002", "卡其色拼黑色,L 建議50-57.5kg", "正常", "sku-9002", "货号A"),
row("1001", "测试商品A", "正常", "9003", "黑色,M", "正常", "sku-9003", "货号A"),
// 商品 B:汇总行 + 1 个解析失败的 SKU(括号不配对)
row("1002", "测试商品B", "正常", "-", "-", "", "", "货号B"),
row("1002", "测试商品B", "正常", "9004", "紅色,3XL建議80-90公斤】", "正常", "sku-9004", "货号B"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
result, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
if result.ProductCount != 2 {
t.Errorf("ProductCount = %d,想要 2", result.ProductCount)
}
if result.SKUCount != 4 {
t.Errorf("SKUCount = %d,想要 4", result.SKUCount)
}
if len(result.Failures) != 1 {
t.Fatalf("Failures 数量 = %d,想要 1(括号不配对那一行)", len(result.Failures))
}
if result.Failures[0].Raw != "紅色,3XL建議80-90公斤】" {
t.Errorf("失败行原文 = %q,不对", result.Failures[0].Raw)
}
if result.Failures[0].Row != 7 {
// 表头第 1 行 + 6 条数据行,"紅色..."是第 6 条数据 -> Excel 行号 7
t.Errorf("失败行行号 = %d,想要 7", result.Failures[0].Row)
}
// 解析失败的 SKU 仍然要写进库:spec_raw 照存,color/size/advice 为空,parse_ok=0。
var color, size, advice sql.NullString
var parseOK int
err = db.QueryRow(
`SELECT color, size, advice, parse_ok FROM shopee_skus WHERE sku_id = ?`, "9004",
).Scan(&color, &size, &advice, &parseOK)
if err != nil {
t.Fatalf("查询失败 SKU 出错: %v", err)
}
if color.Valid && color.String != "" {
t.Errorf("解析失败的行 color 应为空,得到 %q", color.String)
}
if parseOK != 0 {
t.Errorf("解析失败的行 parse_ok 应为 0,得到 %d", parseOK)
}
}
func TestImportShopeeExcel_错误sheet名(t *testing.T) {
db := newTestDB(t)
f := excelize.NewFile()
f.SetSheetName("Sheet1", "新上架商品")
f.NewSheet("高潛力廣告商品")
path := filepath.Join(t.TempDir(), "no_target_sheet.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("保存测试固件失败: %v", err)
}
f.Close()
_, err := ImportShopeeExcel(db, path)
if err == nil {
t.Fatal("缺少「最佳表現商品」sheet 应该报错")
}
msg := err.Error()
if !strings.Contains(msg, "最佳表現商品") || !strings.Contains(msg, "新上架商品") || !strings.Contains(msg, "高潛力廣告商品") {
t.Errorf("错误信息应列出实际有哪些 sheet,得到: %s", msg)
}
}
func TestImportShopeeExcel_缺列(t *testing.T) {
db := newTestDB(t)
// 表头缺了「商品規格ID」,模拟蝦皮加/删列导致列错位的场景。
badHeader := []string{"商品ID", "商品名稱", "商品當前狀態", "商品規格", "規格當前狀態", "商品選項貨號", "主商品貨號"}
path := buildShopeeWorkbook(t, badHeader, nil)
_, err := ImportShopeeExcel(db, path)
if err == nil {
t.Fatal("缺少必需列应该报错")
}
if !strings.Contains(err.Error(), "商品規格ID") {
t.Errorf("错误信息应说明缺的是哪一列,得到: %s", err.Error())
}
}
// TestImportShopeeExcel_不覆盖人工字段 是工单 #38 里最要命的一条:
// 报表里没有 pdd_goods_url / pdd_goods_id 这两列,upsert 绝不能把它们
// 写成空值,否则操作员攒的 PDD 链接会被一次导入洗光,而且不报错。
func TestImportShopeeExcel_不覆盖人工字段(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("2001", "测试商品C", "正常", "-", "-", "", "", "货号C"),
row("2001", "测试商品C", "正常", "8001", "黑色,M", "正常", "sku-8001", "货号C"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
// 先导入一次,让商品存在。
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("第一次导入失败: %v", err)
}
// 模拟操作员手工填了 PDD 链接。
const manualURL = "https://mobile.yangkeduo.com/goods.html?goods_id=999999"
if _, err := db.Exec(
`UPDATE shopee_products SET pdd_goods_url = ?, pdd_goods_id = ? WHERE goods_id = ?`,
manualURL, "999999", "2001",
); err != nil {
t.Fatalf("模拟人工填链接失败: %v", err)
}
// 再导入同一个文件(同一个商品名字也可能变了,模拟蝦皮改了标题)。
rows[0][1] = "测试商品C改名后"
rows[1][1] = "测试商品C改名后"
path2 := buildShopeeWorkbook(t, shopeeHeader, rows)
if _, err := ImportShopeeExcel(db, path2); err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
var gotURL, gotGoodsID, gotTitle sql.NullString
err := db.QueryRow(
`SELECT pdd_goods_url, pdd_goods_id, title FROM shopee_products WHERE goods_id = ?`, "2001",
).Scan(&gotURL, &gotGoodsID, &gotTitle)
if err != nil {
t.Fatalf("查询商品失败: %v", err)
}
if gotURL.String != manualURL {
t.Errorf("pdd_goods_url 被导入洗掉了:got %q, want %q", gotURL.String, manualURL)
}
if gotGoodsID.String != "999999" {
t.Errorf("pdd_goods_id 被导入洗掉了:got %q, want %q", gotGoodsID.String, "999999")
}
// 标题这类报表字段应该正常刷新,证明"不覆盖"只针对人工字段,不是整行不更新。
if gotTitle.String != "测试商品C改名后" {
t.Errorf("title 应该被报表里的新值刷新:got %q", gotTitle.String)
}
}
// TestImportShopeeExcel_不删除人工新增SKU 对应「05」§4.4 提到的人工新增 SKU
// (本工单不实现新增入口,但导入必须留出这个口子,不能做成
// "先 DELETE 再 INSERT" 的全量替换)。
func TestImportShopeeExcel_不删除人工新增SKU(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("3001", "测试商品D", "正常", "-", "-", "", "", "货号D"),
row("3001", "测试商品D", "正常", "7001", "黑色,M", "正常", "sku-7001", "货号D"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("导入失败: %v", err)
}
// 模拟操作员手动新增一个报表里没有的 SKU。
now := model.NowISO()
if _, err := db.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 ('manual-1', '3001', '手动新增-均码', '均码', '', '', 1, '', 1, ?, ?)`,
now, now); err != nil {
t.Fatalf("插入人工新增 SKU 失败: %v", err)
}
// 再导入同一个文件。
if _, err := ImportShopeeExcel(db, path); err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
var isManual int
err := db.QueryRow(`SELECT is_manual FROM shopee_skus WHERE sku_id = 'manual-1'`).Scan(&isManual)
if err != nil {
t.Fatalf("人工新增的 SKU 被导入删掉了: %v", err)
}
if isManual != 1 {
t.Errorf("is_manual 应该还是 1,得到 %d", isManual)
}
}
func TestImportShopeeExcel_二次导入不翻倍(t *testing.T) {
db := newTestDB(t)
rows := [][]string{
row("4001", "测试商品E", "正常", "-", "-", "", "", "货号E"),
row("4001", "测试商品E", "正常", "6001", "黑色,M", "正常", "sku-6001", "货号E"),
row("4001", "测试商品E", "正常", "6002", "白色,L", "正常", "sku-6002", "货号E"),
}
path := buildShopeeWorkbook(t, shopeeHeader, rows)
first, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("第一次导入失败: %v", err)
}
second, err := ImportShopeeExcel(db, path)
if err != nil {
t.Fatalf("第二次导入失败: %v", err)
}
if first.ProductCount != second.ProductCount || first.SKUCount != second.SKUCount {
t.Fatalf("两次导入统计不一致: %+v vs %+v", first, second)
}
var productCount, skuCount int
db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE goods_id = '4001'`).Scan(&productCount)
db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id = '4001'`).Scan(&skuCount)
if productCount != 1 {
t.Errorf("shopee_products 应该只有 1 行,得到 %d(重复导入产生了重复行)", productCount)
}
if skuCount != 2 {
t.Errorf("shopee_skus 应该只有 2 行,得到 %d(重复导入产生了重复行)", skuCount)
}
}