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
+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
}