feat: 回填三个Excel关联PDD正式价格 (#226)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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