蝦皮数据模块此前整个是骨架,四个入口全返回 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>
382 lines
15 KiB
Go
382 lines
15 KiB
Go
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)
|
||
}
|
||
}
|