From 7055519321763c60f0f90c0d4654e3f0c1f482bc Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 14 Aug 2026 16:36:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9E=E5=A1=AB=E4=B8=89=E4=B8=AAExc?= =?UTF-8?q?el=E5=85=B3=E8=81=94PDD=E6=AD=A3=E5=BC=8F=E4=BB=B7=E6=A0=BC=20(?= =?UTF-8?q?#226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/cmd/backfill-xlsx-pdd-prices/main.go | 126 +++++++ admin/repository/price_backfill.go | 86 +++++ admin/service/xlsx_price_backfill.go | 396 +++++++++++++++++++++ admin/service/xlsx_price_backfill_test.go | 176 +++++++++ 4 files changed, 784 insertions(+) create mode 100644 admin/cmd/backfill-xlsx-pdd-prices/main.go create mode 100644 admin/repository/price_backfill.go create mode 100644 admin/service/xlsx_price_backfill.go create mode 100644 admin/service/xlsx_price_backfill_test.go diff --git a/admin/cmd/backfill-xlsx-pdd-prices/main.go b/admin/cmd/backfill-xlsx-pdd-prices/main.go new file mode 100644 index 0000000..57dedc8 --- /dev/null +++ b/admin/cmd/backfill-xlsx-pdd-prices/main.go @@ -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 +} diff --git a/admin/repository/price_backfill.go b/admin/repository/price_backfill.go new file mode 100644 index 0000000..1749697 --- /dev/null +++ b/admin/repository/price_backfill.go @@ -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 +} diff --git a/admin/service/xlsx_price_backfill.go b/admin/service/xlsx_price_backfill.go new file mode 100644 index 0000000..c7fbcc5 --- /dev/null +++ b/admin/service/xlsx_price_backfill.go @@ -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 +} diff --git a/admin/service/xlsx_price_backfill_test.go b/admin/service/xlsx_price_backfill_test.go new file mode 100644 index 0000000..f9fe887 --- /dev/null +++ b/admin/service/xlsx_price_backfill_test.go @@ -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) + } +}