397 lines
13 KiB
Go
397 lines
13 KiB
Go
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
|
||
}
|