diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 31a1321..0d8f9ca 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -591,7 +591,7 @@ func (h *Handler) SybMatch(c *gin.Context) { if actor != nil { operator = actor.UserID } - if err := service.SaveSybMapping(h.db, c.PostForm("syb_id"), c.PostForm("pdd_option_key"), operator); err != nil { + if err := service.SaveSybMapping(h.db, c.PostForm("syb_id"), c.PostForm("pdd_option_key"), operator, c.PostForm("context_version")); err != nil { h.sybRedirect(c, "规格映射未保存:"+err.Error()) return } diff --git a/admin/model/model.go b/admin/model/model.go index c3fc8ef..ca9d9c2 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -279,6 +279,13 @@ type SpecMapping struct { MappedBy string } +type SpecMappingDecision struct { + ShopeeGoodsID, SpecKey, PddGoodsID string + RulesVersion, SuggestedOptionKey, ChosenOptionKey string + Accepted bool + DecidedBy, DecidedAt string +} + // ---------- 任务 ---------- // TaskType 区分采集任务和采购任务。 diff --git a/admin/repository/mapping.go b/admin/repository/mapping.go index d9b7874..35261ed 100644 --- a/admin/repository/mapping.go +++ b/admin/repository/mapping.go @@ -50,3 +50,15 @@ func UpsertSpecMapping(q Execer, m model.SpecMapping) error { } return nil } + +func InsertSpecMappingDecision(q Execer, d model.SpecMappingDecision) error { + _, err := q.Exec(`INSERT INTO spec_mapping_decisions + (shopee_goods_id,spec_key,pdd_goods_id,rules_version,suggested_option_key, + chosen_option_key,accepted,decided_by,decided_at) VALUES (?,?,?,?,?,?,?,?,?)`, + d.ShopeeGoodsID, d.SpecKey, d.PddGoodsID, d.RulesVersion, nullableText(d.SuggestedOptionKey), + d.ChosenOptionKey, d.Accepted, nullableText(d.DecidedBy), d.DecidedAt) + if err != nil { + return fmt.Errorf("写入规格匹配决策审计失败: %w", err) + } + return nil +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 9798782..b28b99d 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -19,7 +19,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 3 +const mysqlSchemaVersion = 4 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -387,6 +387,21 @@ const mysqlSchemaV3SpecMappings = `CREATE TABLE IF NOT EXISTS spec_mappings ( KEY idx_spec_mappings_pdd (pdd_goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` +const mysqlSchemaV4Decisions = `CREATE TABLE IF NOT EXISTS spec_mapping_decisions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + rules_version VARCHAR(32) NOT NULL, + suggested_option_key VARCHAR(191) COLLATE utf8mb4_bin, + chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + accepted TINYINT NOT NULL, + decided_by VARCHAR(191), + decided_at VARCHAR(35) NOT NULL, + CONSTRAINT chk_spec_mapping_decisions_accepted CHECK (accepted IN (0,1)), + KEY idx_decisions_mapping (shopee_goods_id, spec_key, pdd_goods_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` + // MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。 func MigrateMySQL(db *sql.DB) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -437,17 +452,37 @@ func MigrateMySQL(db *sql.DB) error { if err := migrateMySQLV3(db); err != nil { return fmt.Errorf("执行 MySQL schema v3 失败: %w", err) } - if err := CheckMySQLSchema(db); err != nil { + if err := checkMySQLSchemaV3(db); err != nil { return fmt.Errorf("MySQL schema v3 自检失败,未记录版本: %w", err) } if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("记录 MySQL schema v3 失败: %w", err) } + current = 3 + } + if current < 4 { + if _, err := db.Exec(mysqlSchemaV4Decisions); err != nil { + return fmt.Errorf("执行 MySQL schema v4 失败: %w", err) + } + if err := checkMySQLV4Shape(db); err != nil { + return fmt.Errorf("MySQL schema v4 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 4, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v4 失败: %w", err) + } } return CheckMySQLSchema(db) } +func checkMySQLSchemaV3(db *sql.DB) error { + tables := []string{"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", "users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock"} + if err := checkMySQLSchema(db, tables); err != nil { + return err + } + return checkMySQLV3Shape(db) +} + // migrateMySQLV3 把采购规格身份从蝦皮 SKU 改为顺运宝商品规格原文。 // 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。 func migrateMySQLV3(db *sql.DB) error { @@ -686,11 +721,44 @@ func CheckMySQLSchema(db *sql.DB) error { "shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", "users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock", + "spec_mapping_decisions", } if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { return err } - return checkMySQLV3Shape(db) + if err := checkMySQLV3Shape(db); err != nil { + return err + } + return checkMySQLV4Shape(db) +} + +func checkMySQLV4Shape(db *sql.DB) error { + for _, c := range []struct { + name string + length int64 + nullable bool + coll string + }{ + {"shopee_goods_id", 191, false, "utf8mb4_bin"}, {"spec_key", 191, false, "utf8mb4_bin"}, {"pdd_goods_id", 191, false, "utf8mb4_bin"}, + {"rules_version", 32, false, "utf8mb4_0900_ai_ci"}, {"suggested_option_key", 191, true, "utf8mb4_bin"}, {"chosen_option_key", 191, false, "utf8mb4_bin"}, + } { + if err := checkMySQLVarcharColumn(db, "spec_mapping_decisions", c.name, c.length, c.nullable, c.coll, ""); err != nil { + return err + } + } + var enforced, clause string + if err := db.QueryRow(`SELECT tc.enforced,cc.check_clause FROM information_schema.table_constraints tc JOIN information_schema.check_constraints cc ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name WHERE tc.constraint_schema=DATABASE() AND tc.table_name='spec_mapping_decisions' AND tc.constraint_name='chk_spec_mapping_decisions_accepted'`).Scan(&enforced, &clause); err != nil { + return fmt.Errorf("审计 accepted CHECK 缺失: %w", err) + } + n := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(clause)) + if enforced != "YES" || n != "acceptedin0,1" { + return fmt.Errorf("审计 accepted CHECK 不正确") + } + var cols string + if err := db.QueryRow(`SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='spec_mapping_decisions' AND index_name='idx_decisions_mapping'`).Scan(&cols); err != nil || cols != "shopee_goods_id,spec_key,pdd_goods_id" { + return fmt.Errorf("审计映射索引不正确") + } + return nil } func checkMySQLSchema(db *sql.DB, tables []string) error { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index f36167f..93aa5bc 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -183,6 +183,55 @@ func TestMySQLMigrate_V3形状自检失败不记版本(t *testing.T) { } } +func TestMySQLMigrate_V3升级V4且断点重跑(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + prepareMySQLV2(t, db) + if err := migrateMySQLV3(db); err != nil { + t.Fatal(err) + } + mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (3,'2026-08-10T00:00:00Z')`) + // 模拟 DDL 已完成、版本未记录。 + mustExec(t, db, mysqlSchemaV4Decisions) + if err := MigrateMySQL(db); err != nil { + t.Fatal(err) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("重跑失败: %v", err) + } + var versions int + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=4`).Scan(&versions); err != nil || versions != 1 { + t.Fatalf("v4=%d err=%v", versions, err) + } + mustExec(t, db, `INSERT INTO spec_mapping_decisions(shopee_goods_id,spec_key,pdd_goods_id,rules_version,chosen_option_key,accepted,decided_at) VALUES('S','K','P','rules_v1','O',1,'2026-08-10T00:00:00Z')`) + if _, err := db.Exec(`UPDATE spec_mapping_decisions SET accepted=2`); err == nil { + t.Fatal("accepted CHECK 必须拒绝 2") + } +} + +func TestMySQLMigrate_V4形状错误不记版本(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + prepareMySQLV2(t, db) + if err := migrateMySQLV3(db); err != nil { + t.Fatal(err) + } + mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (3,'2026-08-10T00:00:00Z')`) + mustExec(t, db, strings.Replace(mysqlSchemaV4Decisions, "CHECK (accepted IN (0,1))", "CHECK (accepted IN (0,1,2))", 1)) + if err := MigrateMySQL(db); err == nil { + t.Fatal("错误 CHECK 必须阻止 v4") + } + var count int + db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=4`).Scan(&count) + if count != 0 { + t.Fatal("自检失败不得记 v4") + } +} + func openMySQLMigrationTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 0a9e477..315490a 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -346,6 +346,7 @@ type SybOrderContext struct { PddCollectStatus string PddCollectMsg string PddSkusJSON string + PddUpdatedAt string MappingOptionKey string MappingOptions string HasActiveTask bool @@ -356,14 +357,14 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString var priceCent sql.NullInt64 var shopeeExists int - var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString + var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString var mappingKey, mappingOptions sql.NullString var hasActiveTask int err := s.Scan( &c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID, &c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData, &c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists, - &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, + &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt, &mappingKey, &mappingOptions, &hasActiveTask, ) c.Order.Title = title.String @@ -378,6 +379,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { c.PddCollectStatus = collectStatus.String c.PddCollectMsg = collectMsg.String c.PddSkusJSON = skusJSON.String + c.PddUpdatedAt = pddUpdatedAt.String c.MappingOptionKey = mappingKey.String c.MappingOptions = mappingOptions.String c.HasActiveTask = hasActiveTask != 0 @@ -393,7 +395,7 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([ so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at, CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg, - pp.skus_json, sm.pdd_option_key, sm.pdd_options, + pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options, EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' AND t.syb_id = so.syb_id AND t.status IN ('pending', 'assigned', 'claimed'))` + @@ -427,7 +429,7 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) { so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at, CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg, - pp.skus_json, sm.pdd_option_key, sm.pdd_options, + pp.skus_json, pp.updated_at, sm.pdd_option_key, sm.pdd_options, EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase' AND t.syb_id = so.syb_id AND t.status IN ('pending', 'assigned', 'claimed'))`+ diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index 305c043..ba2a51e 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "strings" + "unicode/utf8" "cmautobuy/admin/model" "cmautobuy/admin/repository" @@ -13,19 +14,23 @@ import ( // PddOptionChoice 是采集结果中的一个可购买规格组合。 type PddOptionChoice struct { - Key string - OptionsJSON string - Label string - PriceText string - PriceCent int64 - HasPrice bool - Selected bool + Key string + OptionsJSON string + Label string + PriceText string + PriceCent int64 + HasPrice bool + Selected bool + Options map[string]string + Recommended bool + RecommendationReason string + MatchLevel string } -func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) { +func pddOptionChoices(raw string) ([]PddOptionChoice, []string, []string, error) { collected, err := parseCollected(raw) if err != nil { - return nil, nil, err + return nil, nil, nil, err } keys, names := dimensionOrder(collected) choices := make([]PddOptionChoice, 0, len(collected.SKUs)) @@ -36,7 +41,7 @@ func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) { } key, err := OptionKey(sku.Options) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if seen[key] { continue @@ -52,18 +57,18 @@ func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) { } choice := PddOptionChoice{ Key: key, OptionsJSON: key, Label: strings.Join(parts, " / "), - PriceText: formatPriceCent(sku.PriceCent), + PriceText: formatPriceCent(sku.PriceCent), Options: sku.Options, } if sku.PriceCent != nil && *sku.PriceCent > 0 { choice.PriceCent, choice.HasPrice = *sku.PriceCent, true } choices = append(choices, choice) } - return choices, names, nil + return choices, keys, names, nil } func findPddChoice(raw, key string) (*PddOptionChoice, error) { - choices, _, err := pddOptionChoices(raw) + choices, _, _, err := pddOptionChoices(raw) if err != nil { return nil, err } @@ -77,7 +82,7 @@ func findPddChoice(raw, key string) (*PddOptionChoice, error) { } // SaveSybMapping 保存顺运宝商品规格到当前 PDD 商品的可复用映射。 -func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error { +func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersions ...string) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("开始保存规格映射事务失败: %w", err) @@ -90,6 +95,13 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error { if context == nil { return fmt.Errorf("顺运宝明细不存在") } + expectedContextVersion := mappingContextVersion(*context) + if len(expectedVersions) > 0 { + expectedContextVersion = expectedVersions[0] + } + if expectedContextVersion == "" || mappingContextVersion(*context) != expectedContextVersion { + return fmt.Errorf("数据或匹配规则已变化,请刷新后重新核对") + } key, err := spec.SpecKey(context.Order.ProductSpec) if err != nil || context.Order.SpecKey == "" { return fmt.Errorf("顺运宝未提供有效规格,不能保存映射") @@ -108,6 +120,14 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error { if choice == nil { return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试") } + choices, dimensionKeys, dimensionNames, err := pddOptionChoices(context.PddSkusJSON) + if err != nil { + return fmt.Errorf("读取 PDD 规格失败: %w", err) + } + match := rankSpecChoices(context.Order.ProductSpec, choices, dimensionKeys, dimensionNames) + if utf8.RuneCountInString(choice.Key) > 191 || utf8.RuneCountInString(match.SuggestedOptionKey) > 191 || utf8.RuneCountInString(SpecMatchRulesVersion) > 32 { + return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存") + } if err := repository.UpsertSpecMapping(tx, model.SpecMapping{ ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID, @@ -116,6 +136,14 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error { }); err != nil { return err } + if err := repository.InsertSpecMappingDecision(tx, model.SpecMappingDecision{ + ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, PddGoodsID: context.PddGoodsID, + RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey, + ChosenOptionKey: choice.Key, Accepted: match.SuggestedOptionKey != "" && match.SuggestedOptionKey == choice.Key, + DecidedBy: strings.TrimSpace(operator), DecidedAt: model.NowISO(), + }); err != nil { + return err + } return tx.Commit() } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index e2e5e93..159a216 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -130,6 +130,82 @@ func TestSybMapping_换品和选项消失都会失效(t *testing.T) { }) } +func TestSybMapping_建议审计追加陈旧保护与事务回滚(t *testing.T) { + db := newTestDB(t) + order := seedWorkflowOrder(t, db, "SYB-AUDIT", "SP-AUDIT", "灰色,L建議53-57公斤") + if _, err := AssociateShopeePdd(db, "SP-AUDIT", pddURLA, false); err != nil { + t.Fatal(err) + } + raw := `{"goods_id":"737116531267","dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(80-115斤)"},"price_cent":3990,"available":true},{"options":{"color":"黑色","size":"L(80-115斤)"},"price_cent":3990,"available":true}]}` + if err := repository.SetCollectResult(db, "737116531267", "PDD", "", raw); err != nil { + t.Fatal(err) + } + detail, err := GetSybProcessingDetail(db, order.SybID) + if err != nil { + t.Fatal(err) + } + var suggested, other string + for _, c := range detail.PddChoices { + if c.Recommended { + suggested = c.Key + } else { + other = c.Key + } + } + if suggested == "" || detail.ContextVersion == "" { + t.Fatalf("无建议: %+v", detail) + } + var unopenedMappings, unopenedDecisions int + db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedMappings) + db.QueryRow(`SELECT COUNT(*) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedDecisions) + if unopenedMappings != 0 || unopenedDecisions != 0 { + t.Fatal("只打开或关闭弹窗不得写库") + } + if err := SaveSybMapping(db, order.SybID, other, "USR", detail.ContextVersion); err != nil { + t.Fatal(err) + } + if err := SaveSybMapping(db, order.SybID, suggested, "USR", mappingContextVersionMust(t, db, order.SybID)); err != nil { + t.Fatal(err) + } + var total, accepted int + db.QueryRow(`SELECT COUNT(*),SUM(accepted) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&total, &accepted) + if total != 2 || accepted != 1 { + t.Fatalf("审计 total=%d accepted=%d", total, accepted) + } + old := mappingContextVersionMust(t, db, order.SybID) + if err := repository.SetCollectResult(db, "737116531267", "PDD2", "", raw+" "); err != nil { + t.Fatal(err) + } + if err := SaveSybMapping(db, order.SybID, suggested, "USR", old); err == nil { + t.Fatal("陈旧弹窗必须拒绝") + } + before := suggested + mustExecService(t, db, `DROP TABLE spec_mapping_decisions`) + if err := SaveSybMapping(db, order.SybID, other, "USR", mappingContextVersionMust(t, db, order.SybID)); err == nil { + t.Fatal("审计失败必须回滚") + } + var current string + db.QueryRow(`SELECT pdd_option_key FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(¤t) + if current != before { + t.Fatal("映射未回滚") + } +} + +func mappingContextVersionMust(t *testing.T, db *sql.DB, id string) string { + t.Helper() + c, e := repository.GetSybOrderContext(db, id) + if e != nil { + t.Fatal(e) + } + return mappingContextVersion(*c) +} +func mustExecService(t *testing.T, db *sql.DB, q string) { + t.Helper() + if _, e := db.Exec(q); e != nil { + t.Fatal(e) + } +} + func stringsReplaceGoodsID(raw string) string { return `{"goods_id":"937122477375","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}],"skus":[{"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true}]}` } diff --git a/admin/service/specmatch.go b/admin/service/specmatch.go new file mode 100644 index 0000000..c6002f7 --- /dev/null +++ b/admin/service/specmatch.go @@ -0,0 +1,264 @@ +package service + +import ( + "fmt" + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + SpecMatchRulesVersion = "rules_v1" + maxCandidateWidthRatio = 5.0 + minCandidateWidthAllowanceJin = 40.0 + maxMidpointDistanceJin = 20.0 +) + +type matchLevel int + +const ( + matchConflict matchLevel = iota + matchC + matchB + matchA +) + +type specFeatures struct { + size string + weightFrom float64 + weightTo float64 + hasWeight bool + colors map[string]bool +} + +type SpecMatchResult struct { + Choices []PddOptionChoice + SuggestedOptionKey string + PreselectOptionKey string + Notice string +} + +var traditionalPairs = []struct{ from, to string }{ + {"藍", "蓝"}, {"個", "个"}, {"規", "规"}, {"長", "长"}, {"碼", "码"}, + {"綠", "绿"}, {"紅", "红"}, {"淺", "浅"}, {"裝", "装"}, {"條", "条"}, +} + +var weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*[-~~至到]\s*(\d+(?:\.\d+)?)\s*(公斤|kg|斤)`) +var sizePattern = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])((?:[2-9]xl)|(?:x{2,9}l)|xl|xs|s|m|l|均码|f)(?:码)?(?:$|[^a-z])`) + +var colorAliases = []struct { + word string + set []string +}{ + {"粉红", []string{"粉"}}, {"浅粉", []string{"粉"}}, {"深粉", []string{"粉"}}, {"粉色", []string{"粉"}}, + {"米白", []string{"米", "白"}}, {"藏青", []string{"藏青"}}, {"藏蓝", []string{"藏青"}}, + {"卡其", []string{"卡其"}}, {"咖啡", []string{"咖啡"}}, + {"黑", []string{"黑"}}, {"白", []string{"白"}}, {"灰", []string{"灰"}}, {"红", []string{"红"}}, + {"蓝", []string{"蓝"}}, {"绿", []string{"绿"}}, {"黄", []string{"黄"}}, {"紫", []string{"紫"}}, + {"粉", []string{"粉"}}, {"米", []string{"米"}}, {"青", []string{"青"}}, +} + +func simplifyExplicit(raw string) string { + for _, pair := range traditionalPairs { + raw = strings.ReplaceAll(raw, pair.from, pair.to) + } + return raw +} + +func extractSpecFeatures(raw string) specFeatures { + normalized := simplifyExplicit(raw) + f := specFeatures{colors: map[string]bool{}} + if match := weightPattern.FindStringSubmatch(normalized); len(match) > 0 { + f.weightFrom, _ = strconv.ParseFloat(match[1], 64) + f.weightTo, _ = strconv.ParseFloat(match[2], 64) + if strings.EqualFold(match[3], "公斤") || strings.EqualFold(match[3], "kg") { + f.weightFrom *= 2 + f.weightTo *= 2 + } + if f.weightFrom > f.weightTo { + f.weightFrom, f.weightTo = f.weightTo, f.weightFrom + } + f.hasWeight = true + } + if match := sizePattern.FindStringSubmatch(strings.ToLower(normalized)); len(match) > 0 { + f.size = normalizeSize(match[1]) + } + colorText := normalized + for _, alias := range colorAliases { + if strings.Contains(colorText, alias.word) { + for _, value := range alias.set { + f.colors[value] = true + } + colorText = strings.ReplaceAll(colorText, alias.word, strings.Repeat(" ", len([]rune(alias.word)))) + } + } + return f +} + +func normalizeSize(raw string) string { + if raw == "均码" { + return raw + } + raw = strings.ToUpper(strings.TrimSuffix(raw, "码")) + if strings.HasSuffix(raw, "L") && strings.TrimSuffix(raw, "L") != "" { + prefix := strings.TrimSuffix(raw, "L") + if strings.Trim(prefix, "X") == "" && len(prefix) >= 2 { + return fmt.Sprintf("%dXL", len(prefix)) + } + } + return raw +} + +type rankedChoice struct { + choice PddOptionChoice + level matchLevel + coverage, iou, midpoint float64 + index int +} + +func rankSpecChoices(raw string, choices []PddOptionChoice, keys, names []string) SpecMatchResult { + source := extractSpecFeatures(raw) + colorKey, sizeKey, ambiguous, extra := identifyDimensions(choices, keys, names) + ranked := make([]rankedChoice, 0, len(choices)) + for i, choice := range choices { + text := choice.Label + if colorKey != "" || sizeKey != "" { + text = choice.Options[colorKey] + " " + choice.Options[sizeKey] + } + candidate := extractSpecFeatures(text) + level, coverage, iou, midpoint, reason := classifySpec(source, candidate) + choice.RecommendationReason = reason + choice.MatchLevel = []string{"冲突", "C", "B", "A"}[level] + ranked = append(ranked, rankedChoice{choice, level, coverage, iou, midpoint, i}) + } + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].level != ranked[j].level { + return ranked[i].level > ranked[j].level + } + if ranked[i].coverage != ranked[j].coverage { + return ranked[i].coverage > ranked[j].coverage + } + if ranked[i].iou != ranked[j].iou { + return ranked[i].iou > ranked[j].iou + } + return ranked[i].midpoint < ranked[j].midpoint + }) + result := SpecMatchResult{Choices: make([]PddOptionChoice, len(ranked))} + aCount := 0 + for i := range ranked { + result.Choices[i] = ranked[i].choice + if ranked[i].level == matchA { + aCount++ + } + } + if aCount == 1 { + result.SuggestedOptionKey = result.Choices[0].Key + result.Choices[0].Recommended = true + if !ambiguous && !extra { + result.PreselectOptionKey = result.Choices[0].Key + } + } + if aCount > 1 { + result.Notice = "有多个 A 级候选,已稳定置顶但不会预选。" + } + if ambiguous || extra { + result.Notice = "存在无法判断的额外规格维度,请人工核对;系统不会预选。" + } + return result +} + +func identifyDimensions(choices []PddOptionChoice, keys, names []string) (color, size string, ambiguous, extra bool) { + colorAliases := map[string]bool{"color": true, "colour": true, "颜色": true, "颜色分类": true, "色系": true} + sizeAliases := map[string]bool{"size": true, "尺码": true, "尺寸": true, "大小": true, "码数": true} + for i, key := range keys { + name := key + if i < len(names) { + name += " " + names[i] + } + parts := strings.Fields(strings.ToLower(simplifyExplicit(name))) + isColor, isSize := false, false + for _, p := range parts { + if colorAliases[p] { + isColor = true + } + if sizeAliases[p] { + isSize = true + } + } + if isColor { + if color != "" && color != key { + ambiguous = true + } + color = key + } + if isSize { + if size != "" && size != key { + ambiguous = true + } + size = key + } + } + for _, key := range keys { + if key == color || key == size { + continue + } + values := map[string]bool{} + for _, c := range choices { + values[c.Options[key]] = true + } + if len(values) > 1 { + extra = true + } + } + return +} + +func classifySpec(a, b specFeatures) (matchLevel, float64, float64, float64, string) { + colorKnown := len(a.colors) > 0 && len(b.colors) > 0 + colorHit := false + for c := range a.colors { + if b.colors[c] { + colorHit = true + } + } + if colorKnown && !colorHit { + return matchConflict, 0, 0, math.MaxFloat64, "主色明确冲突,请勿选择。" + } + sizeEqual := a.size != "" && a.size == b.size + coverage, iou, mid := 0.0, 0.0, math.MaxFloat64 + if a.hasWeight && b.hasWeight { + inter := math.Max(0, math.Min(a.weightTo, b.weightTo)-math.Max(a.weightFrom, b.weightFrom)) + target := a.weightTo - a.weightFrom + union := math.Max(a.weightTo, b.weightTo) - math.Min(a.weightFrom, b.weightFrom) + if target == 0 { + if a.weightFrom >= b.weightFrom && a.weightFrom <= b.weightTo { + coverage = 1 + } + } else { + coverage = inter / target + } + if union > 0 { + iou = inter / union + } + mid = math.Abs((a.weightFrom + a.weightTo - b.weightFrom - b.weightTo) / 2) + if sizeEqual && inter == 0 { + return matchConflict, coverage, iou, mid, "尺码码位一致,但体重区间完全不重叠。" + } + } + contained := a.hasWeight && b.hasWeight && a.weightFrom >= b.weightFrom && a.weightTo <= b.weightTo + widthOK := false + if contained { + width := b.weightTo - b.weightFrom + target := a.weightTo - a.weightFrom + widthOK = width <= math.Max(target*maxCandidateWidthRatio, minCandidateWidthAllowanceJin) && mid <= maxMidpointDistanceJin + } + if sizeEqual && contained && widthOK && colorHit { + return matchA, coverage, iou, mid, fmt.Sprintf("尺码码位一致(%s);目标体重区间被候选完整覆盖;主色一致。", a.size) + } + if sizeEqual && coverage > 0 && (!colorKnown || colorHit) { + return matchB, coverage, iou, mid, "尺码码位一致,体重区间部分重叠;请人工核对。" + } + return matchC, coverage, iou, mid, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。" +} diff --git a/admin/service/specmatch_test.go b/admin/service/specmatch_test.go new file mode 100644 index 0000000..2407736 --- /dev/null +++ b/admin/service/specmatch_test.go @@ -0,0 +1,74 @@ +package service + +import "testing" + +func testChoices(values ...map[string]string) ([]PddOptionChoice, []string, []string) { + choices := make([]PddOptionChoice, 0, len(values)) + for _, v := range values { + k, _ := OptionKey(v) + choices = append(choices, PddOptionChoice{Key: k, Label: v["color"] + " " + v["size"], Options: v}) + } + return choices, []string{"color", "size"}, []string{"颜色分类", "尺码"} +} + +func TestSpecMatch_带标签样本(t *testing.T) { + cases := []struct { + name, raw string + options []map[string]string + pre bool + level string + }{ + {"正确第一名", "灰色-小個子,L建議53-57公斤", []map[string]string{{"color": "灰色中长款", "size": "L(106-114斤)"}, {"color": "黑色", "size": "L(106-114斤)"}}, true, "A"}, + {"繁简单位", "藍色,5XL建議100-120公斤", []map[string]string{{"color": "蓝色长款", "size": "5XL(200-240斤)"}}, true, "A"}, + {"部分重叠", "灰色,L建議55-60公斤", []map[string]string{{"color": "灰色", "size": "L(115-130斤)"}}, false, "B"}, + {"无码位无主色", "53-57公斤", []map[string]string{{"color": "", "size": "L(80-115斤)"}}, false, "C"}, + {"体重冲突", "L建議52-60公斤", []map[string]string{{"color": "", "size": "L(200-240斤)"}}, false, "冲突"}, + {"颜色冲突", "黑色常規款,L建議52-60公斤", []map[string]string{{"color": "白色中长款", "size": "L(104-120斤)"}}, false, "冲突"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, k, n := testChoices(tc.options...) + r := rankSpecChoices(tc.raw, c, k, n) + if len(r.Choices) == 0 || r.Choices[0].MatchLevel != tc.level || (r.PreselectOptionKey != "") != tc.pre { + t.Fatalf("result=%+v", r) + } + }) + } +} + +func TestSpecMatch_繁简表与颜色别名逐条覆盖(t *testing.T) { + for _, p := range traditionalPairs { + if simplifyExplicit(p.from) != p.to { + t.Fatalf("%s", p.from) + } + } + for _, raw := range []string{"粉紅", "浅粉", "米白", "藏藍"} { + if len(extractSpecFeatures(raw).colors) == 0 { + t.Fatalf("%s", raw) + } + } + if !extractSpecFeatures("粉紅色").colors["粉"] || !extractSpecFeatures("粉色系").colors["粉"] { + t.Fatal("粉色别名未归一") + } + if !extractSpecFeatures("藏青").colors["藏青"] { + t.Fatal("藏青被拆分") + } +} + +func TestSpecMatch_尺码最长优先(t *testing.T) { + for raw, want := range map[string]string{"XXL": "2XL", "2XL": "2XL", "XXXXXL": "5XL", "女M码": "M", "均码": "均码", "F": "F"} { + if got := extractSpecFeatures(raw).size; got != want { + t.Fatalf("%s=>%s", raw, got) + } + } +} + +func TestSpecMatch_多维歧义不预选(t *testing.T) { + c, k, n := testChoices(map[string]string{"color": "灰", "size": "L(106-114斤)", "style": "A"}, map[string]string{"color": "灰", "size": "L(106-114斤)", "style": "B"}) + k = append(k, "style") + n = append(n, "款式") + r := rankSpecChoices("灰色,L建議53-57公斤", c, k, n) + if r.PreselectOptionKey != "" || r.Notice == "" { + t.Fatalf("%+v", r) + } +} diff --git a/admin/service/syb.go b/admin/service/syb.go index c3f6d93..36d010f 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -12,6 +12,7 @@ package service import ( "context" + "crypto/sha256" "database/sql" "encoding/json" "errors" @@ -28,6 +29,13 @@ import ( "cmautobuy/admin/syb" ) +func mappingContextVersion(c repository.SybOrderContext) string { + payload, _ := json.Marshal([]string{c.Order.SybID, c.Order.SpecKey, c.Order.UpdatedAt, + c.Order.ProductSpec, strconv.Itoa(c.Order.Quantity), c.PddGoodsID, c.PddUpdatedAt, + c.PddSkusJSON, SpecMatchRulesVersion}) + return fmt.Sprintf("%x", sha256.Sum256(payload)) +} + const ( dateLayout = "2006-01-02" maxSpecifiedSyncDays = 31 @@ -1138,25 +1146,27 @@ func defaultMaxPrice(context repository.SybOrderContext) string { // SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。 type SybProcessingDetail struct { - SybID string - OrderNo string - Title string - ProductSpec string - ShopeeGoodsID string - Quantity int - Stage string - StageText string - StageHelp string - ShopeeExists bool - PddGoodsID string - PddURL string - CollectStatus string - CollectMsg string - CanCollect bool - PddDimensionNames []string - PddChoices []PddOptionChoice - MappingValid bool - HasActiveTask bool + SybID string + OrderNo string + Title string + ProductSpec string + ShopeeGoodsID string + Quantity int + Stage string + StageText string + StageHelp string + ShopeeExists bool + PddGoodsID string + PddURL string + CollectStatus string + CollectMsg string + CanCollect bool + PddDimensionNames []string + PddChoices []PddOptionChoice + ContextVersion string + RecommendationNotice string + MappingValid bool + HasActiveTask bool } func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) { @@ -1177,18 +1187,22 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err context.PddCollectStatus == string(model.CollectFailed)) d.HasActiveTask = context.HasActiveTask if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" { - choices, names, parseErr := pddOptionChoices(context.PddSkusJSON) + choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON) if parseErr == nil { d.PddDimensionNames = names - d.PddChoices = choices + match := rankSpecChoices(o.ProductSpec, choices, keys, names) + d.PddChoices = match.Choices + d.RecommendationNotice = match.Notice for i := range d.PddChoices { - d.PddChoices[i].Selected = d.PddChoices[i].Key == context.MappingOptionKey + d.PddChoices[i].Selected = d.PddChoices[i].Key == context.MappingOptionKey || + (context.MappingOptionKey == "" && d.PddChoices[i].Key == match.PreselectOptionKey) if d.PddChoices[i].Selected { d.MappingValid = true } } } } + d.ContextVersion = mappingContextVersion(*context) d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context) return d, nil } diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 6f1ce47..2b4e474 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -57,6 +57,7 @@
{{else}}最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。
diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index dda7750..555ee94 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -219,6 +219,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 下次遇到同商品、同规格和同 PDD 商品时自动带出。 - `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口, 而不是显示一个空列表让人困惑。 +- 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。 +- 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。 **创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗要求 选择当前账号可见的客户端,并逐行确认人民币价格上限;服务端仍按 §5 全量复核。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 13efb33..e313524 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -599,6 +599,38 @@ OptionKey(map[string]string{"size": "M", "color": "黑色"}) 只要有一点点不同(空格、转义、键序),映射就会静默对不上。 **Client 只上报 `options` 对象,key 一律由 Admin 这一个函数算。** +### 6.3 `spec_mapping_decisions` 决策审计 + +本表只追加、不参与业务判断。每次成功保存映射时,与 `spec_mappings` 在同一事务写入; +没有建议时 `suggested_option_key` 为 NULL 且 `accepted=0`。`accepted=0` 表示未采纳建议或当时无建议。 + +```sql +CREATE TABLE spec_mapping_decisions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + rules_version VARCHAR(32) NOT NULL, + suggested_option_key VARCHAR(191) COLLATE utf8mb4_bin, + chosen_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + accepted TINYINT NOT NULL CHECK (accepted IN (0, 1)), + decided_by VARCHAR(191), + decided_at VARCHAR(35) NOT NULL, + KEY idx_decisions_mapping (shopee_goods_id, spec_key, pdd_goods_id) +); +``` + +下面的指标只能称为“成功保存决策中的建议采纳率”,不是准确率: + +```sql +SELECT rules_version, COUNT(*) AS 有建议的决策数, SUM(accepted) AS 采纳数, + ROUND(SUM(accepted) / COUNT(*) * 100, 1) AS 采纳率 +FROM spec_mapping_decisions WHERE suggested_option_key IS NOT NULL GROUP BY rules_version; + +SELECT rules_version, COUNT(*) AS 无建议决策数 +FROM spec_mapping_decisions WHERE suggested_option_key IS NULL GROUP BY rules_version; +``` + ## 7. `tasks` 任务 采集任务和采购任务共用一张表,用 `task_type` 区分。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index b87509c..4ab0564 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -519,6 +519,9 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 - 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。 - 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。 - 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。 +- 候选按确定性 A/B/C/冲突规则稳定排序,界面不显示数字分数。只有唯一 A 级且无额外维度歧义时预选,但仍必须人工点“保存”。 +- `★ 建议` 同时显示码位、体重覆盖和主色等文字理由;保存按钮旁固定提示“请核对颜色、尺码和体重范围”。 +- 弹窗携带完整匹配上下文版本;顺运宝明细、PDD 关联/采集结果或规则版本改变后拒绝陈旧保存。 ### 6.4 创建采购任务