From abc37db4866225a68d5e0e99bccca0bbe8ba615a Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 14 Aug 2026 17:08:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A1=AE=E8=AE=A4=E5=9B=9E=E5=A1=ABPDD?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E5=8F=AF=E7=94=A8=E4=BA=8E=E9=87=87=E8=B4=AD?= =?UTF-8?q?=20(#227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main.go | 121 +++++++++++++ admin/repository/availability_confirmation.go | 25 +++ .../service/xlsx_availability_confirmation.go | 163 ++++++++++++++++++ .../xlsx_availability_confirmation_test.go | 111 ++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 admin/cmd/confirm-backfilled-pdd-availability/main.go create mode 100644 admin/repository/availability_confirmation.go create mode 100644 admin/service/xlsx_availability_confirmation.go create mode 100644 admin/service/xlsx_availability_confirmation_test.go diff --git a/admin/cmd/confirm-backfilled-pdd-availability/main.go b/admin/cmd/confirm-backfilled-pdd-availability/main.go new file mode 100644 index 0000000..9dcf57c --- /dev/null +++ b/admin/cmd/confirm-backfilled-pdd-availability/main.go @@ -0,0 +1,121 @@ +// confirm-backfilled-pdd-availability 把三个固定 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"` + Files []string `json:"files"` + Items []backupItem `json:"items"` +} + +type backupItem struct { + PDDGoodsID string `json:"pdd_goods_id"` + 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.BuildXLSXAvailabilityPlan(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.ApplyXLSXAvailabilityPlan(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.XLSXAvailabilityPlan, 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候选 SKU:%d\n本次更新 PDD:%d\n分类:%s\n", + mode, plan.Shop, plan.Sources.RowCount, plan.Sources.ShopeeProductCount, + plan.Sources.PDDProductCount, len(plan.Candidates), skuCount, updated, summary) +} + +func writeBackup(plan service.XLSXAvailabilityPlan) (string, error) { + file, err := os.CreateTemp("", "cmautobuy_xlsx_availability_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), + Files: append([]string(nil), service.XLSXPriceBackfillFiles...), + } + for _, candidate := range plan.Candidates { + original := candidate.Original + backup.Items = append(backup.Items, backupItem{ + PDDGoodsID: candidate.PDDGoodsID, 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/availability_confirmation.go b/admin/repository/availability_confirmation.go new file mode 100644 index 0000000..9ad9c5b --- /dev/null +++ b/admin/repository/availability_confirmation.go @@ -0,0 +1,25 @@ +package repository + +import "fmt" + +// ReplaceBackfilledPDDAvailability 只替换规格 JSON 和更新时间。 +// collect_status、价格、关联及采集时间由新 JSON 和原行乐观锁共同保护,不在此处改写。 +func ReplaceBackfilledPDDAvailability(q Execer, target PDDPriceBackfillTarget, + newSKUsJSON, updatedAt string) (bool, error) { + result, err := q.Exec(` + UPDATE pdd_products + SET skus_json = ?, updated_at = ? + WHERE goods_id = ? AND deleted_at IS NULL + AND COALESCE(skus_json, '') = ? + AND collect_status = ? AND updated_at = ?`, + newSKUsJSON, 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_availability_confirmation.go b/admin/service/xlsx_availability_confirmation.go new file mode 100644 index 0000000..c74ffe3 --- /dev/null +++ b/admin/service/xlsx_availability_confirmation.go @@ -0,0 +1,163 @@ +package service + +import ( + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +const ( + XLSXAvailabilityEligible = "candidate" + XLSXAvailabilityPDDMissing = "pdd_missing_or_deleted" + XLSXAvailabilityStatusUnsupported = "status_not_collected" + XLSXAvailabilityJSONInvalid = "pdd_json_invalid" + XLSXAvailabilitySourceMismatch = "spec_source_not_shopee_backfill" + XLSXAvailabilitySKUsMissing = "pdd_skus_missing" + XLSXAvailabilitySpecsInvalid = "pdd_specs_invalid" + XLSXAvailabilityPriceInvalid = "pdd_price_invalid" + XLSXAvailabilityAlreadyDecided = "availability_already_decided" + XLSXAvailabilityMarkerInvalid = "availability_marker_invalid" +) + +// XLSXAvailabilityCandidate 是一件可安全确认导入规格可用于采购的 PDD 商品。 +type XLSXAvailabilityCandidate struct { + PDDGoodsID string + SKUCount int + Original repository.PDDPriceBackfillTarget + NewSKUsJSON string +} + +// XLSXAvailabilityPlan 保存 dry-run 统计和事务更新需要的完整原值。 +type XLSXAvailabilityPlan struct { + Shop string + GeneratedAt string + Sources XLSXPriceSources + Summary map[string]int + Candidates []XLSXAvailabilityCandidate +} + +// BuildXLSXAvailabilityPlan 仅在内存中生成三个固定 Excel 的可售确认计划。 +func BuildXLSXAvailabilityPlan(sources XLSXPriceSources, + targets []repository.PDDPriceBackfillTarget, now time.Time) XLSXAvailabilityPlan { + plan := XLSXAvailabilityPlan{ + 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 { + target, exists := targetByID[goodsID] + if !exists { + plan.Summary[XLSXAvailabilityPDDMissing]++ + continue + } + if target.CollectStatus != string(model.CollectCollected) { + plan.Summary[XLSXAvailabilityStatusUnsupported]++ + continue + } + newRaw, skuCount, status := confirmBackfilledPDDAvailability(target.SKUsJSON) + if status != XLSXAvailabilityEligible { + plan.Summary[status]++ + continue + } + plan.Summary[XLSXAvailabilityEligible]++ + plan.Candidates = append(plan.Candidates, XLSXAvailabilityCandidate{ + PDDGoodsID: goodsID, SKUCount: skuCount, Original: target, NewSKUsJSON: newRaw, + }) + } + return plan +} + +func confirmBackfilledPDDAvailability(raw string) (string, int, string) { + var root map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &root); err != nil || root == nil { + return "", 0, XLSXAvailabilityJSONInvalid + } + var source string + if err := json.Unmarshal(root["spec_source"], &source); err != nil || source != "shopee_backfill" { + return "", 0, XLSXAvailabilitySourceMismatch + } + var skus []map[string]json.RawMessage + if err := json.Unmarshal(root["skus"], &skus); err != nil { + return "", 0, XLSXAvailabilityJSONInvalid + } + if len(skus) == 0 { + return "", 0, XLSXAvailabilitySKUsMissing + } + for _, sku := range skus { + var options map[string]string + if err := json.Unmarshal(sku["options"], &options); err != nil || len(options) == 0 { + return "", 0, XLSXAvailabilitySpecsInvalid + } + for key, value := range options { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + return "", 0, XLSXAvailabilitySpecsInvalid + } + } + var price *int64 + priceRaw, hasPrice := sku["price_cent"] + if !hasPrice || json.Unmarshal(priceRaw, &price) != nil || price == nil || *price <= 0 { + return "", 0, XLSXAvailabilityPriceInvalid + } + if available, exists := sku["available"]; exists && strings.TrimSpace(string(available)) != "null" { + return "", 0, XLSXAvailabilityAlreadyDecided + } + var marker string + if err := json.Unmarshal(sku["availability_status"], &marker); err != nil || marker != "unknown" { + return "", 0, XLSXAvailabilityMarkerInvalid + } + } + for _, sku := range skus { + sku["available"] = json.RawMessage("true") + delete(sku, "availability_status") + } + encodedSKUs, err := json.Marshal(skus) + if err != nil { + return "", 0, XLSXAvailabilityJSONInvalid + } + root["skus"] = encodedSKUs + encodedRoot, err := json.Marshal(root) + if err != nil { + return "", 0, XLSXAvailabilityJSONInvalid + } + return string(encodedRoot), len(skus), XLSXAvailabilityEligible +} + +// ApplyXLSXAvailabilityPlan 在一个事务中确认全部候选;任一原值变化则整批回滚。 +func ApplyXLSXAvailabilityPlan(db *sql.DB, plan XLSXAvailabilityPlan, now time.Time) (int, error) { + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("开始确认 PDD 可售状态事务失败: %w", err) + } + defer tx.Rollback() + updatedAt := now.UTC().Format(model.TimeLayout) + updated := 0 + for _, candidate := range plan.Candidates { + ok, err := repository.ReplaceBackfilledPDDAvailability( + tx, candidate.Original, candidate.NewSKUsJSON, updatedAt) + 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("提交 PDD 可售状态确认失败: %w", err) + } + return updated, nil +} diff --git a/admin/service/xlsx_availability_confirmation_test.go b/admin/service/xlsx_availability_confirmation_test.go new file mode 100644 index 0000000..c218a30 --- /dev/null +++ b/admin/service/xlsx_availability_confirmation_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "database/sql" + "encoding/json" + "testing" + "time" + + "cmautobuy/admin/repository" + _ "modernc.org/sqlite" +) + +func TestBuildXLSXAvailabilityPlanConfirmsOnlySafeImportedSpecs(t *testing.T) { + safe := `{"spec_source":"shopee_backfill","dimensions":[{"key":"color","name":"颜色"}],"skus":[` + + `{"options":{"color":"黑"},"price_cent":2490,"list_price_cent":null,"inventory":7,"availability_status":"unknown"},` + + `{"options":{"color":"白"},"price_cent":2490,"list_price_cent":3000,"availability_status":"unknown","available":null}]}` + sources := XLSXPriceSources{PDDPrices: map[string]XLSXPDDPriceSource{ + "GOOD": {}, "DECIDED": {}, "BAD_PRICE": {}, "WRONG_SOURCE": {}, "PENDING": {}, "MISSING": {}, + }} + targets := []repository.PDDPriceBackfillTarget{ + {GoodsID: "GOOD", SKUsJSON: safe, CollectStatus: "collected", UpdatedAt: "old"}, + {GoodsID: "DECIDED", SKUsJSON: `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"price_cent":100,"availability_status":"unknown","available":false}]}`, CollectStatus: "collected", UpdatedAt: "old"}, + {GoodsID: "BAD_PRICE", SKUsJSON: `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"price_cent":0,"availability_status":"unknown"}]}`, CollectStatus: "collected", UpdatedAt: "old"}, + {GoodsID: "WRONG_SOURCE", SKUsJSON: `{"spec_source":"client","skus":[{"options":{"color":"黑"},"price_cent":100,"availability_status":"unknown"}]}`, CollectStatus: "collected", UpdatedAt: "old"}, + {GoodsID: "PENDING", SKUsJSON: safe, CollectStatus: "pending", UpdatedAt: "old"}, + } + plan := BuildXLSXAvailabilityPlan(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[XLSXAvailabilityEligible] != 1 || plan.Summary[XLSXAvailabilityAlreadyDecided] != 1 || + plan.Summary[XLSXAvailabilityPriceInvalid] != 1 || plan.Summary[XLSXAvailabilitySourceMismatch] != 1 || + plan.Summary[XLSXAvailabilityStatusUnsupported] != 1 || plan.Summary[XLSXAvailabilityPDDMissing] != 1 { + t.Fatalf("跳过分类不正确: %+v", plan.Summary) + } + var root struct { + SpecSource string `json:"spec_source"` + SKUs []struct { + Options map[string]string `json:"options"` + PriceCent int64 `json:"price_cent"` + ListPriceCent *int64 `json:"list_price_cent"` + Inventory int `json:"inventory"` + Available *bool `json:"available"` + AvailabilityStatus *string `json:"availability_status"` + } `json:"skus"` + } + if err := json.Unmarshal([]byte(plan.Candidates[0].NewSKUsJSON), &root); err != nil { + t.Fatal(err) + } + if root.SpecSource != "shopee_backfill" || len(root.SKUs) != 2 { + t.Fatalf("来源或 SKU 数被改坏: %+v", root) + } + for _, sku := range root.SKUs { + if sku.Available == nil || !*sku.Available || sku.AvailabilityStatus != nil || sku.PriceCent != 2490 { + t.Fatalf("应只确认 available 并删除 unknown 标记: %+v", sku) + } + } + if root.SKUs[0].Inventory != 7 || root.SKUs[1].ListPriceCent == nil || *root.SKUs[1].ListPriceCent != 3000 { + t.Fatalf("库存和列表价必须保留: %+v", root.SKUs) + } +} + +func TestConfirmBackfilledPDDAvailabilityRejectsPartialOrAmbiguousRows(t *testing.T) { + tests := []struct { + name, raw, want string + }{ + {"空规格", `{"spec_source":"shopee_backfill","skus":[{"options":{},"price_cent":100,"availability_status":"unknown"}]}`, XLSXAvailabilitySpecsInvalid}, + {"缺价格", `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"availability_status":"unknown"}]}`, XLSXAvailabilityPriceInvalid}, + {"缺未知标记", `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"price_cent":100}]}`, XLSXAvailabilityMarkerInvalid}, + {"已有可售", `{"spec_source":"shopee_backfill","skus":[{"options":{"color":"黑"},"price_cent":100,"availability_status":"unknown","available":true}]}`, XLSXAvailabilityAlreadyDecided}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, _, got := confirmBackfilledPDDAvailability(test.raw); got != test.want { + t.Fatalf("状态=%q,期望 %q", got, test.want) + } + }) + } +} + +func TestApplyXLSXAvailabilityPlanUsesWholeBatchRollback(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, + 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,updated_at) + VALUES('P1','old-1','collected','t1'),('P2','changed','collected','t2')`); err != nil { + t.Fatal(err) + } + plan := XLSXAvailabilityPlan{Candidates: []XLSXAvailabilityCandidate{ + {PDDGoodsID: "P1", NewSKUsJSON: `{"skus":[1]}`, Original: repository.PDDPriceBackfillTarget{GoodsID: "P1", SKUsJSON: "old-1", CollectStatus: "collected", UpdatedAt: "t1"}}, + {PDDGoodsID: "P2", NewSKUsJSON: `{"skus":[2]}`, Original: repository.PDDPriceBackfillTarget{GoodsID: "P2", SKUsJSON: "old-2", CollectStatus: "collected", UpdatedAt: "t2"}}, + }} + if _, err := ApplyXLSXAvailabilityPlan(db, plan, time.Now()); err == nil { + t.Fatal("第二件原值变化时必须失败") + } + var raw, status, updatedAt string + if err := db.QueryRow(`SELECT skus_json,collect_status,updated_at FROM pdd_products WHERE goods_id='P1'`).Scan(&raw, &status, &updatedAt); err != nil { + t.Fatal(err) + } + if raw != "old-1" || status != "collected" || updatedAt != "t1" { + t.Fatalf("整批必须回滚,实际 raw=%q status=%q updated_at=%q", raw, status, updatedAt) + } +}