Files
cmautobuy/admin/service/xlsx_price_backfill.go

397 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}