package service import ( "database/sql" "encoding/json" "path/filepath" "testing" "time" "cmautobuy/admin/repository" "github.com/xuri/excelize/v2" _ "modernc.org/sqlite" ) func TestParseXLSXMaxTWDAndConvert(t *testing.T) { tests := []struct { raw string wantMax string wantCents int64 }{ {"249", "249", 2490}, {"314~630", "630", 6300}, {"12.35~10", "12.35", 124}, } for _, test := range tests { maximum, err := parseXLSXMaxTWD(test.raw) if err != nil { t.Fatalf("解析 %q 失败: %v", test.raw, err) } cents, err := twdToCNYCent(maximum) if err != nil || decimalText(maximum) != test.wantMax || cents != test.wantCents { t.Errorf("%q => max=%s cents=%d err=%v,期望 %s/%d", test.raw, decimalText(maximum), cents, err, test.wantMax, test.wantCents) } } if _, err := parseXLSXMaxTWD("249 元左右"); err == nil { t.Fatal("不受支持的价格文本必须拒绝,不能猜测") } } func TestLoadXLSXPriceSourcesUsesOnlyThreeFilesAndGlobalPDDMax(t *testing.T) { dir := t.TempDir() writePriceWorkbook(t, filepath.Join(dir, XLSXPriceBackfillFiles[0]), [][]any{ {XLSXPriceBackfillShop, "S1", "P1", "100~249"}, {XLSXPriceBackfillShop, "S1", "P1", "249"}, {"other-shop", "OTHER", "OTHER-PDD", "9999"}, }) writePriceWorkbook(t, filepath.Join(dir, XLSXPriceBackfillFiles[1]), [][]any{ {XLSXPriceBackfillShop, "S2", "P1", "300"}, }) writePriceWorkbook(t, filepath.Join(dir, XLSXPriceBackfillFiles[2]), [][]any{ {XLSXPriceBackfillShop, "S3", "P2", "12.35"}, }) sources, err := LoadXLSXPriceSources(dir) if err != nil { t.Fatal(err) } if sources.RowCount != 4 || sources.ShopeeProductCount != 3 || sources.PDDProductCount != 2 || sources.SharedPDDWithDifferentMaxCount != 1 { t.Fatalf("汇总不正确: %+v", sources) } if got := sources.PDDPrices["P1"]; got.MaxTWD != "300" || got.PriceCent != 3000 || got.ShopeeProductCount != 2 { t.Fatalf("共用 PDD 应取所有蝦皮商品的全局最大值: %+v", got) } if got := sources.PDDPrices["P2"]; got.PriceCent != 124 { t.Fatalf("小数应四舍五入到人民币分: %+v", got) } } func TestLoadXLSXPriceSourcesRequiresEveryFixedFile(t *testing.T) { dir := t.TempDir() writePriceWorkbook(t, filepath.Join(dir, XLSXPriceBackfillFiles[0]), [][]any{{XLSXPriceBackfillShop, "S1", "P1", "249"}}) if _, err := LoadXLSXPriceSources(dir); err == nil { t.Fatal("缺少另外两个固定文件时必须停止") } } func TestBuildXLSXPriceBackfillPlanOnlyFillsSafeSkeleton(t *testing.T) { skeleton := `{"spec_source":"shopee_backfill","skus":[` + `{"options":{"color":"黑","size":"M"},"price_cent":null,"list_price_cent":null,"availability_status":"unknown"},` + `{"options":{"color":"白","size":"L"},"price_cent":null,"list_price_cent":null,"availability_status":"unknown"}]}` withPrice := `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"price_cent":100,"availability_status":"unknown"}]}` sources := XLSXPriceSources{PDDProductCount: 5, PDDPrices: map[string]XLSXPDDPriceSource{ "GOOD": {PDDGoodsID: "GOOD", MaxTWD: "249", PriceCent: 2490}, "PRICED": {PDDGoodsID: "PRICED", MaxTWD: "300", PriceCent: 3000}, "COLLECTED": {PDDGoodsID: "COLLECTED", MaxTWD: "400", PriceCent: 4000}, "WRONG": {PDDGoodsID: "WRONG", MaxTWD: "500", PriceCent: 5000}, "MISSING": {PDDGoodsID: "MISSING", MaxTWD: "600", PriceCent: 6000}, }} targets := []repository.PDDPriceBackfillTarget{ {GoodsID: "GOOD", SKUsJSON: skeleton, CollectStatus: "failed", UpdatedAt: "old"}, {GoodsID: "PRICED", SKUsJSON: withPrice, CollectStatus: "pending", UpdatedAt: "old"}, {GoodsID: "COLLECTED", SKUsJSON: skeleton, CollectStatus: "collected", UpdatedAt: "old"}, {GoodsID: "WRONG", SKUsJSON: `{"spec_source":"other","skus":[]}`, CollectStatus: "pending", UpdatedAt: "old"}, } plan := BuildXLSXPriceBackfillPlan(sources, targets, time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC)) if len(plan.Candidates) != 1 || plan.Candidates[0].PDDGoodsID != "GOOD" || plan.Candidates[0].SKUCount != 2 { t.Fatalf("只应生成一个安全候选: %+v", plan) } if plan.Summary[XLSXPriceCandidate] != 1 || plan.Summary[XLSXPriceAlreadyExists] != 1 || plan.Summary[XLSXPriceAlreadyCollected] != 1 || plan.Summary[XLSXPriceSourceMismatch] != 1 || plan.Summary[XLSXPricePDDMissing] != 1 { t.Fatalf("跳过分类不正确: %+v", plan.Summary) } var root struct { SKUs []struct { PriceCent *int64 `json:"price_cent"` ListPriceCent *int64 `json:"list_price_cent"` AvailabilityStatus string `json:"availability_status"` } `json:"skus"` } if err := json.Unmarshal([]byte(plan.Candidates[0].NewSKUsJSON), &root); err != nil { t.Fatal(err) } for _, sku := range root.SKUs { if sku.PriceCent == nil || *sku.PriceCent != 2490 || sku.ListPriceCent != nil || sku.AvailabilityStatus != "unknown" { t.Fatalf("只应填正式价格并保留列表价和库存: %+v", sku) } } } func TestApplyXLSXPriceBackfillUsesWholeBatchRollback(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() db.SetMaxOpenConns(1) if _, err := db.Exec(`CREATE TABLE pdd_products( goods_id TEXT PRIMARY KEY, skus_json TEXT, collect_status TEXT, collect_msg TEXT, artifact_ref TEXT, collected_at TEXT, deleted_at TEXT, updated_at TEXT)`); err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,skus_json,collect_status,collect_msg,updated_at) VALUES('P1','old-1','pending','旧错误','t1'),('P2','changed','pending',NULL,'t2')`); err != nil { t.Fatal(err) } plan := XLSXPriceBackfillPlan{Candidates: []XLSXPriceBackfillCandidate{ {PDDGoodsID: "P1", NewSKUsJSON: `{"skus":[1]}`, Original: repository.PDDPriceBackfillTarget{GoodsID: "P1", SKUsJSON: "old-1", CollectStatus: "pending", UpdatedAt: "t1"}}, {PDDGoodsID: "P2", NewSKUsJSON: `{"skus":[2]}`, Original: repository.PDDPriceBackfillTarget{GoodsID: "P2", SKUsJSON: "old-2", CollectStatus: "pending", UpdatedAt: "t2"}}, }} if _, err := ApplyXLSXPriceBackfill(db, plan, time.Now()); err == nil { t.Fatal("第二件原值变化时必须失败") } var raw, status, message string if err := db.QueryRow(`SELECT skus_json,collect_status,collect_msg FROM pdd_products WHERE goods_id='P1'`).Scan(&raw, &status, &message); err != nil { t.Fatal(err) } if raw != "old-1" || status != "pending" || message != "旧错误" { t.Fatalf("整批必须回滚,实际 raw=%q status=%q message=%q", raw, status, message) } } func writePriceWorkbook(t *testing.T, path string, rows [][]any) { t.Helper() book := excelize.NewFile() defer book.Close() if err := book.SetSheetName("Sheet1", xlsxPriceSheet); err != nil { t.Fatal(err) } header := []any{"店铺显示名", "商品ID", "货源ID", "价格"} if err := book.SetSheetRow(xlsxPriceSheet, "A1", &header); err != nil { t.Fatal(err) } for index, row := range rows { cell, _ := excelize.CoordinatesToCellName(1, index+2) if err := book.SetSheetRow(xlsxPriceSheet, cell, &row); err != nil { t.Fatal(err) } } if err := book.SaveAs(path); err != nil { t.Fatal(err) } }