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 }