fix: 确认回填PDD规格可用于采购 (#227)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user