feat: 回填三个Excel关联PDD正式价格 (#226)
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
XLSXPriceBackfillShop = "qwg8fkb044"
|
||||
xlsxPriceSheet = "在线商品"
|
||||
|
||||
XLSXPriceCandidate = "candidate"
|
||||
XLSXPricePDDMissing = "pdd_missing_or_deleted"
|
||||
XLSXPriceAlreadyCollected = "already_collected"
|
||||
XLSXPriceStatusUnsupported = "status_unsupported"
|
||||
XLSXPriceJSONInvalid = "pdd_json_invalid"
|
||||
XLSXPriceSourceMismatch = "spec_source_not_shopee_backfill"
|
||||
XLSXPriceSKUsMissing = "pdd_skus_missing"
|
||||
XLSXPriceSpecsInvalid = "pdd_specs_invalid"
|
||||
XLSXPriceAlreadyExists = "pdd_price_already_exists"
|
||||
)
|
||||
|
||||
var XLSXPriceBackfillFiles = []string{
|
||||
"shopee_chanpin_1_已处理.xlsx",
|
||||
"shopee_chanpin_2_已处理.xlsx",
|
||||
"shopee_chanpin_3_已处理.xlsx",
|
||||
}
|
||||
|
||||
var xlsxPricePattern = regexp.MustCompile(`^\s*(\d+(?:\.\d+)?)\s*(?:[~~\-—–]\s*(\d+(?:\.\d+)?))?\s*$`)
|
||||
|
||||
// XLSXPDDPriceSource 是三个 Excel 归并后的单个 PDD 正式价格来源。
|
||||
type XLSXPDDPriceSource struct {
|
||||
PDDGoodsID string
|
||||
MaxTWD string
|
||||
PriceCent int64
|
||||
ShopeeProductCount int
|
||||
}
|
||||
|
||||
// XLSXPriceSources 是三个固定工作簿的只读解析汇总。
|
||||
type XLSXPriceSources struct {
|
||||
RowCount int
|
||||
ShopeeProductCount int
|
||||
PDDProductCount int
|
||||
SharedPDDWithDifferentMaxCount int
|
||||
PDDPrices map[string]XLSXPDDPriceSource
|
||||
}
|
||||
|
||||
type xlsxShopeePrice struct {
|
||||
pddGoodsID string
|
||||
maxTWD *big.Rat
|
||||
}
|
||||
|
||||
// LoadXLSXPriceSources 只读取白名单中的三个固定文件,并只处理目标店铺。
|
||||
func LoadXLSXPriceSources(dir string) (XLSXPriceSources, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return XLSXPriceSources{}, fmt.Errorf("Excel 目录不能为空")
|
||||
}
|
||||
products := make(map[string]xlsxShopeePrice)
|
||||
result := XLSXPriceSources{PDDPrices: make(map[string]XLSXPDDPriceSource)}
|
||||
|
||||
for _, name := range XLSXPriceBackfillFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return XLSXPriceSources{}, fmt.Errorf("读取固定 Excel %s 失败: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return XLSXPriceSources{}, fmt.Errorf("固定 Excel %s 不是普通文件", name)
|
||||
}
|
||||
rows, err := loadXLSXPriceFile(path, products)
|
||||
if err != nil {
|
||||
return XLSXPriceSources{}, err
|
||||
}
|
||||
if rows == 0 {
|
||||
return XLSXPriceSources{}, fmt.Errorf("固定 Excel %s 没有店铺 %s 的数据", name, XLSXPriceBackfillShop)
|
||||
}
|
||||
result.RowCount += rows
|
||||
}
|
||||
|
||||
pricesByPDD := make(map[string]map[string]struct{})
|
||||
maxByPDD := make(map[string]*big.Rat)
|
||||
productCountByPDD := make(map[string]int)
|
||||
for _, product := range products {
|
||||
canonical := product.maxTWD.RatString()
|
||||
if pricesByPDD[product.pddGoodsID] == nil {
|
||||
pricesByPDD[product.pddGoodsID] = make(map[string]struct{})
|
||||
}
|
||||
pricesByPDD[product.pddGoodsID][canonical] = struct{}{}
|
||||
productCountByPDD[product.pddGoodsID]++
|
||||
currentMax, exists := maxByPDD[product.pddGoodsID]
|
||||
if !exists || product.maxTWD.Cmp(currentMax) > 0 {
|
||||
maxByPDD[product.pddGoodsID] = new(big.Rat).Set(product.maxTWD)
|
||||
}
|
||||
}
|
||||
for pddGoodsID, maximum := range maxByPDD {
|
||||
priceCent, err := twdToCNYCent(maximum)
|
||||
if err != nil {
|
||||
return XLSXPriceSources{}, fmt.Errorf("PDD 商品 %s 的价格无法转换: %w", pddGoodsID, err)
|
||||
}
|
||||
result.PDDPrices[pddGoodsID] = XLSXPDDPriceSource{
|
||||
PDDGoodsID: pddGoodsID, MaxTWD: decimalText(maximum), PriceCent: priceCent,
|
||||
ShopeeProductCount: productCountByPDD[pddGoodsID],
|
||||
}
|
||||
}
|
||||
for _, prices := range pricesByPDD {
|
||||
if len(prices) > 1 {
|
||||
result.SharedPDDWithDifferentMaxCount++
|
||||
}
|
||||
}
|
||||
result.ShopeeProductCount = len(products)
|
||||
result.PDDProductCount = len(result.PDDPrices)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadXLSXPriceFile(path string, products map[string]xlsxShopeePrice) (int, error) {
|
||||
book, err := excelize.OpenFile(path, excelize.Options{RawCellValue: true})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("打开固定 Excel %s 失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer book.Close()
|
||||
if index, err := book.GetSheetIndex(xlsxPriceSheet); err != nil || index < 0 {
|
||||
return 0, fmt.Errorf("固定 Excel %s 缺少工作表 %q", filepath.Base(path), xlsxPriceSheet)
|
||||
}
|
||||
rows, err := book.Rows(xlsxPriceSheet)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取固定 Excel %s 失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, fmt.Errorf("固定 Excel %s 是空文件", filepath.Base(path))
|
||||
}
|
||||
headers, err := rows.Columns()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取固定 Excel %s 表头失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
indexes := make(map[string]int)
|
||||
for index, header := range headers {
|
||||
indexes[strings.TrimSpace(header)] = index
|
||||
}
|
||||
required := []string{"店铺显示名", "商品ID", "货源ID", "价格"}
|
||||
for _, header := range required {
|
||||
if _, exists := indexes[header]; !exists {
|
||||
return 0, fmt.Errorf("固定 Excel %s 缺少列 %q", filepath.Base(path), header)
|
||||
}
|
||||
}
|
||||
|
||||
matchedRows := 0
|
||||
rowNumber := 1
|
||||
for rows.Next() {
|
||||
rowNumber++
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取固定 Excel %s 第 %d 行失败: %w", filepath.Base(path), rowNumber, err)
|
||||
}
|
||||
if xlsxColumn(columns, indexes["店铺显示名"]) != XLSXPriceBackfillShop {
|
||||
continue
|
||||
}
|
||||
matchedRows++
|
||||
shopeeID := xlsxColumn(columns, indexes["商品ID"])
|
||||
pddID := xlsxColumn(columns, indexes["货源ID"])
|
||||
priceRaw := xlsxColumn(columns, indexes["价格"])
|
||||
if shopeeID == "" || pddID == "" || priceRaw == "" {
|
||||
return 0, fmt.Errorf("固定 Excel %s 第 %d 行商品ID、货源ID和价格不能为空", filepath.Base(path), rowNumber)
|
||||
}
|
||||
maxTWD, err := parseXLSXMaxTWD(priceRaw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("固定 Excel %s 第 %d 行价格 %q 无效: %w", filepath.Base(path), rowNumber, priceRaw, err)
|
||||
}
|
||||
current, exists := products[shopeeID]
|
||||
if exists && current.pddGoodsID != pddID {
|
||||
return 0, fmt.Errorf("蝦皮商品 %s 同时关联 PDD %s 和 %s", shopeeID, current.pddGoodsID, pddID)
|
||||
}
|
||||
if !exists || maxTWD.Cmp(current.maxTWD) > 0 {
|
||||
products[shopeeID] = xlsxShopeePrice{pddGoodsID: pddID, maxTWD: maxTWD}
|
||||
}
|
||||
}
|
||||
if err := rows.Error(); err != nil {
|
||||
return 0, fmt.Errorf("遍历固定 Excel %s 失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
return matchedRows, nil
|
||||
}
|
||||
|
||||
func xlsxColumn(columns []string, index int) string {
|
||||
if index < 0 || index >= len(columns) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(columns[index])
|
||||
}
|
||||
|
||||
func parseXLSXMaxTWD(raw string) (*big.Rat, error) {
|
||||
matches := xlsxPricePattern.FindStringSubmatch(raw)
|
||||
if matches == nil {
|
||||
return nil, fmt.Errorf("只允许单价或用 ~ 连接的价格区间")
|
||||
}
|
||||
first, ok := new(big.Rat).SetString(matches[1])
|
||||
if !ok || first.Sign() <= 0 {
|
||||
return nil, fmt.Errorf("价格必须大于 0")
|
||||
}
|
||||
maximum := first
|
||||
if matches[2] != "" {
|
||||
second, ok := new(big.Rat).SetString(matches[2])
|
||||
if !ok || second.Sign() <= 0 {
|
||||
return nil, fmt.Errorf("价格必须大于 0")
|
||||
}
|
||||
if second.Cmp(maximum) > 0 {
|
||||
maximum = second
|
||||
}
|
||||
}
|
||||
return new(big.Rat).Set(maximum), nil
|
||||
}
|
||||
|
||||
// twdToCNYCent 按用户确认公式「台币 ÷ 10 × 100」转换,并对正数四舍五入到分。
|
||||
func twdToCNYCent(twd *big.Rat) (int64, error) {
|
||||
if twd == nil || twd.Sign() <= 0 {
|
||||
return 0, fmt.Errorf("台币价格必须大于 0")
|
||||
}
|
||||
value := new(big.Rat).Mul(twd, big.NewRat(10, 1))
|
||||
quotient, remainder := new(big.Int), new(big.Int)
|
||||
quotient.QuoRem(value.Num(), value.Denom(), remainder)
|
||||
if new(big.Int).Mul(remainder, big.NewInt(2)).Cmp(value.Denom()) >= 0 {
|
||||
quotient.Add(quotient, big.NewInt(1))
|
||||
}
|
||||
if !quotient.IsInt64() || quotient.Sign() <= 0 {
|
||||
return 0, fmt.Errorf("转换结果超出人民币分的范围")
|
||||
}
|
||||
return quotient.Int64(), nil
|
||||
}
|
||||
|
||||
func decimalText(value *big.Rat) string {
|
||||
if value.IsInt() {
|
||||
return value.Num().String()
|
||||
}
|
||||
text := value.FloatString(8)
|
||||
return strings.TrimRight(strings.TrimRight(text, "0"), ".")
|
||||
}
|
||||
|
||||
// XLSXPriceBackfillCandidate 是一件可安全回填正式价格的 PDD 商品。
|
||||
type XLSXPriceBackfillCandidate struct {
|
||||
PDDGoodsID string
|
||||
SourceMaxTWD string
|
||||
PriceCent int64
|
||||
SKUCount int
|
||||
Original repository.PDDPriceBackfillTarget
|
||||
NewSKUsJSON string
|
||||
}
|
||||
|
||||
// XLSXPriceBackfillPlan 保存 dry-run 的完整统计和应用所需原值。
|
||||
type XLSXPriceBackfillPlan struct {
|
||||
Shop string
|
||||
GeneratedAt string
|
||||
Sources XLSXPriceSources
|
||||
Summary map[string]int
|
||||
Candidates []XLSXPriceBackfillCandidate
|
||||
}
|
||||
|
||||
// BuildXLSXPriceBackfillPlan 只在内存中生成计划,不写数据库。
|
||||
func BuildXLSXPriceBackfillPlan(sources XLSXPriceSources,
|
||||
targets []repository.PDDPriceBackfillTarget, now time.Time) XLSXPriceBackfillPlan {
|
||||
plan := XLSXPriceBackfillPlan{
|
||||
Shop: XLSXPriceBackfillShop, GeneratedAt: now.UTC().Format(model.TimeLayout),
|
||||
Sources: sources, Summary: make(map[string]int),
|
||||
}
|
||||
targetByID := make(map[string]repository.PDDPriceBackfillTarget, len(targets))
|
||||
for _, target := range targets {
|
||||
targetByID[target.GoodsID] = target
|
||||
}
|
||||
goodsIDs := make([]string, 0, len(sources.PDDPrices))
|
||||
for goodsID := range sources.PDDPrices {
|
||||
goodsIDs = append(goodsIDs, goodsID)
|
||||
}
|
||||
sort.Strings(goodsIDs)
|
||||
for _, goodsID := range goodsIDs {
|
||||
source := sources.PDDPrices[goodsID]
|
||||
target, exists := targetByID[goodsID]
|
||||
if !exists {
|
||||
plan.Summary[XLSXPricePDDMissing]++
|
||||
continue
|
||||
}
|
||||
if target.CollectStatus == string(model.CollectCollected) {
|
||||
plan.Summary[XLSXPriceAlreadyCollected]++
|
||||
continue
|
||||
}
|
||||
if target.CollectStatus != string(model.CollectPending) && target.CollectStatus != string(model.CollectFailed) {
|
||||
plan.Summary[XLSXPriceStatusUnsupported]++
|
||||
continue
|
||||
}
|
||||
newRaw, skuCount, status := fillBackfilledPDDPrices(target.SKUsJSON, source.PriceCent)
|
||||
if status != XLSXPriceCandidate {
|
||||
plan.Summary[status]++
|
||||
continue
|
||||
}
|
||||
plan.Summary[XLSXPriceCandidate]++
|
||||
plan.Candidates = append(plan.Candidates, XLSXPriceBackfillCandidate{
|
||||
PDDGoodsID: goodsID, SourceMaxTWD: source.MaxTWD, PriceCent: source.PriceCent,
|
||||
SKUCount: skuCount, Original: target, NewSKUsJSON: newRaw,
|
||||
})
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func fillBackfilledPDDPrices(raw string, priceCent int64) (string, int, string) {
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &root); err != nil || root == nil {
|
||||
return "", 0, XLSXPriceJSONInvalid
|
||||
}
|
||||
var source string
|
||||
if err := json.Unmarshal(root["spec_source"], &source); err != nil || source != "shopee_backfill" {
|
||||
return "", 0, XLSXPriceSourceMismatch
|
||||
}
|
||||
var skus []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(root["skus"], &skus); err != nil {
|
||||
return "", 0, XLSXPriceJSONInvalid
|
||||
}
|
||||
if len(skus) == 0 {
|
||||
return "", 0, XLSXPriceSKUsMissing
|
||||
}
|
||||
for _, sku := range skus {
|
||||
var options map[string]string
|
||||
if err := json.Unmarshal(sku["options"], &options); err != nil || len(options) == 0 {
|
||||
return "", 0, XLSXPriceSpecsInvalid
|
||||
}
|
||||
for key, value := range options {
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
|
||||
return "", 0, XLSXPriceSpecsInvalid
|
||||
}
|
||||
}
|
||||
priceRaw, hasPrice := sku["price_cent"]
|
||||
if !hasPrice {
|
||||
return "", 0, XLSXPriceSpecsInvalid
|
||||
}
|
||||
if strings.TrimSpace(string(priceRaw)) != "null" {
|
||||
return "", 0, XLSXPriceAlreadyExists
|
||||
}
|
||||
var availability string
|
||||
if err := json.Unmarshal(sku["availability_status"], &availability); err != nil || availability != "unknown" {
|
||||
return "", 0, XLSXPriceSpecsInvalid
|
||||
}
|
||||
if available, exists := sku["available"]; exists && strings.TrimSpace(string(available)) != "null" {
|
||||
return "", 0, XLSXPriceSpecsInvalid
|
||||
}
|
||||
}
|
||||
encodedPrice := json.RawMessage(strconv.FormatInt(priceCent, 10))
|
||||
for _, sku := range skus {
|
||||
sku["price_cent"] = encodedPrice
|
||||
}
|
||||
encodedSKUs, err := json.Marshal(skus)
|
||||
if err != nil {
|
||||
return "", 0, XLSXPriceJSONInvalid
|
||||
}
|
||||
root["skus"] = encodedSKUs
|
||||
encodedRoot, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return "", 0, XLSXPriceJSONInvalid
|
||||
}
|
||||
return string(encodedRoot), len(skus), XLSXPriceCandidate
|
||||
}
|
||||
|
||||
// ApplyXLSXPriceBackfill 在单个事务中应用计划;任一目标变化即整批回滚。
|
||||
func ApplyXLSXPriceBackfill(db *sql.DB, plan XLSXPriceBackfillPlan, now time.Time) (int, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("开始 Excel PDD 正式价格回填事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
updated := 0
|
||||
for _, candidate := range plan.Candidates {
|
||||
ok, err := repository.ReplacePDDPricesAndMarkCollected(
|
||||
tx, candidate.Original, candidate.NewSKUsJSON, at, at)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PDD 商品 %s 在 dry-run 后发生变化,已回滚整批价格回填", candidate.PDDGoodsID)
|
||||
}
|
||||
updated++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("提交 Excel PDD 正式价格回填失败: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user