feat: 回填三个Excel关联PDD正式价格 (#226)

This commit is contained in:
chengma
2026-08-14 16:36:42 +08:00
parent 619394ac7d
commit 7055519321
4 changed files with 784 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
// backfill-xlsx-pdd-prices 用三个固定蝦皮 Excel 的价格最大值回填关联 PDD 正式价格。
// 默认只预览;只有显式传入 --apply 才写数据库。
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"
"cmautobuy/admin/config"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
type backupFile struct {
Shop string `json:"shop"`
GeneratedAt string `json:"generated_at"`
Formula string `json:"formula"`
Files []string `json:"files"`
Items []backupItem `json:"items"`
}
type backupItem struct {
PDDGoodsID string `json:"pdd_goods_id"`
SourceMaxTWD string `json:"source_max_twd"`
PriceCent int64 `json:"price_cent"`
OriginalSKUsJSON string `json:"original_skus_json"`
OriginalCollectStatus string `json:"original_collect_status"`
OriginalCollectMsg *string `json:"original_collect_msg"`
OriginalArtifactRef *string `json:"original_artifact_ref"`
OriginalCollectedAt *string `json:"original_collected_at"`
OriginalUpdatedAt string `json:"original_updated_at"`
}
func main() {
dir := flag.String("dir", "../raw_data", "三个固定 Excel 所在目录")
apply := flag.Bool("apply", false, "实际写入;不传时只做 dry-run")
flag.Parse()
if flag.NArg() != 0 {
log.Fatal("不接受额外文件参数;只会读取 --dir 下三个固定文件名")
}
sources, err := service.LoadXLSXPriceSources(*dir)
if err != nil {
log.Fatalf("读取三个固定 Excel 失败: %v", err)
}
cfg, err := config.LoadDatabase()
if err != nil {
log.Fatalf("读取 MySQL 配置失败: %v", err)
}
db, err := repository.OpenMySQL(cfg)
if err != nil {
log.Fatalf("连接 MySQL 失败: %v", err)
}
defer db.Close()
targets, err := repository.ListShopPDDPriceBackfillTargets(db, service.XLSXPriceBackfillShop)
if err != nil {
log.Fatal(err)
}
plan := service.BuildXLSXPriceBackfillPlan(sources, targets, time.Now())
if !*apply {
printReport(plan, 0, "dry-run")
return
}
if len(plan.Candidates) == 0 {
printReport(plan, 0, "applied-noop")
return
}
backupPath, err := writeBackup(plan)
if err != nil {
log.Fatalf("创建本机回退备份失败,未写数据库: %v", err)
}
updated, err := service.ApplyXLSXPriceBackfill(db, plan, time.Now())
if err != nil {
log.Fatalf("PDD 正式价格回填失败: %v;本机备份位于 %s", err, backupPath)
}
printReport(plan, updated, "applied")
fmt.Printf("本机回退备份:%s\n", backupPath)
}
func printReport(plan service.XLSXPriceBackfillPlan, updated int, mode string) {
summary, _ := json.Marshal(plan.Summary)
skuCount := 0
for _, candidate := range plan.Candidates {
skuCount += candidate.SKUCount
}
fmt.Printf("模式:%s\n店铺:%s\nExcel 行:%d\n蝦皮商品:%d\nPDD 商品:%d\n共用 PDD 且最大价不同:%d\n候选 PDD:%d\n候选 SKU:%d\n本次更新 PDD:%d\n分类:%s\n",
mode, plan.Shop, plan.Sources.RowCount, plan.Sources.ShopeeProductCount,
plan.Sources.PDDProductCount, plan.Sources.SharedPDDWithDifferentMaxCount,
len(plan.Candidates), skuCount, updated, summary)
}
func writeBackup(plan service.XLSXPriceBackfillPlan) (string, error) {
file, err := os.CreateTemp("", "cmautobuy_xlsx_price_backup_*.json")
if err != nil {
return "", err
}
path := file.Name()
defer file.Close()
if err := file.Chmod(0o600); err != nil {
return "", err
}
backup := backupFile{
Shop: plan.Shop, GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Formula: "max_twd / 10 * 100 = cny_cent", Files: append([]string(nil), service.XLSXPriceBackfillFiles...),
}
for _, candidate := range plan.Candidates {
original := candidate.Original
backup.Items = append(backup.Items, backupItem{
PDDGoodsID: candidate.PDDGoodsID, SourceMaxTWD: candidate.SourceMaxTWD,
PriceCent: candidate.PriceCent, OriginalSKUsJSON: original.SKUsJSON,
OriginalCollectStatus: original.CollectStatus, OriginalCollectMsg: original.CollectMsg,
OriginalArtifactRef: original.ArtifactRef, OriginalCollectedAt: original.CollectedAt,
OriginalUpdatedAt: original.UpdatedAt,
})
}
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(backup); err != nil {
return "", err
}
return path, nil
}
+86
View File
@@ -0,0 +1,86 @@
package repository
import (
"database/sql"
"fmt"
"strings"
)
// PDDPriceBackfillTarget 是一次性 Excel 价格回填需要的 PDD 原值。
// 原值同时用于 dry-run、乐观锁和本机回退备份。
type PDDPriceBackfillTarget struct {
GoodsID string
SKUsJSON string
CollectStatus string
CollectMsg *string
ArtifactRef *string
CollectedAt *string
UpdatedAt string
}
// ListShopPDDPriceBackfillTargets 读取指定蝦皮店铺当前关联的活动 PDD 商品。
// 同一个 PDD 被多个蝦皮商品关联时只返回一份原值。
func ListShopPDDPriceBackfillTargets(q Execer, shopName string) ([]PDDPriceBackfillTarget, error) {
rows, err := q.Query(`
SELECT DISTINCT pp.goods_id, COALESCE(pp.skus_json, ''), pp.collect_status,
pp.collect_msg, pp.artifact_ref, pp.collected_at, pp.updated_at
FROM shopee_products sp
JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id
WHERE sp.deleted_at IS NULL AND pp.deleted_at IS NULL
AND sp.shopee_shop_name = ?
ORDER BY pp.goods_id`, strings.TrimSpace(shopName))
if err != nil {
return nil, fmt.Errorf("读取店铺 PDD 价格回填目标失败: %w", err)
}
defer rows.Close()
var targets []PDDPriceBackfillTarget
for rows.Next() {
var target PDDPriceBackfillTarget
var collectMsg, artifactRef, collectedAt sql.NullString
if err := rows.Scan(&target.GoodsID, &target.SKUsJSON, &target.CollectStatus,
&collectMsg, &artifactRef, &collectedAt, &target.UpdatedAt); err != nil {
return nil, fmt.Errorf("解析 PDD 价格回填目标失败: %w", err)
}
target.CollectMsg = nullableStringPointer(collectMsg)
target.ArtifactRef = nullableStringPointer(artifactRef)
target.CollectedAt = nullableStringPointer(collectedAt)
targets = append(targets, target)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历 PDD 价格回填目标失败: %w", err)
}
return targets, nil
}
func nullableStringPointer(value sql.NullString) *string {
if !value.Valid {
return nil
}
result := value.String
return &result
}
// ReplacePDDPricesAndMarkCollected 使用完整原值作乐观锁,写入价格并标记已采集。
// 返回 false 表示 dry-run 后目标发生变化,调用方必须回滚整批,不能部分提交。
func ReplacePDDPricesAndMarkCollected(q Execer, target PDDPriceBackfillTarget,
newSKUsJSON, collectedAt, updatedAt string) (bool, error) {
result, err := q.Exec(`
UPDATE pdd_products
SET skus_json = ?, collect_status = 'collected',
collect_msg = NULL, artifact_ref = NULL,
collected_at = ?, updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL
AND COALESCE(skus_json, '') = ?
AND collect_status = ? AND updated_at = ?`,
newSKUsJSON, collectedAt, updatedAt, target.GoodsID,
target.SKUsJSON, target.CollectStatus, target.UpdatedAt)
if err != nil {
return false, fmt.Errorf("更新 PDD 商品 %s 的正式价格失败: %w", target.GoodsID, err)
}
rows, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("读取 PDD 商品 %s 的价格回填结果失败: %w", target.GoodsID, err)
}
return rows == 1, nil
}
+396
View File
@@ -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
}
+176
View File
@@ -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)
}
}