feat: 增加规格匹配建议与决策审计 (#90)

This commit is contained in:
chengma
2026-08-10 12:41:17 +08:00
parent 5abe81e128
commit 5a77bde534
15 changed files with 680 additions and 46 deletions
+1 -1
View File
@@ -591,7 +591,7 @@ func (h *Handler) SybMatch(c *gin.Context) {
if actor != nil { if actor != nil {
operator = actor.UserID 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()) h.sybRedirect(c, "规格映射未保存:"+err.Error())
return return
} }
+7
View File
@@ -279,6 +279,13 @@ type SpecMapping struct {
MappedBy string MappedBy string
} }
type SpecMappingDecision struct {
ShopeeGoodsID, SpecKey, PddGoodsID string
RulesVersion, SuggestedOptionKey, ChosenOptionKey string
Accepted bool
DecidedBy, DecidedAt string
}
// ---------- 任务 ---------- // ---------- 任务 ----------
// TaskType 区分采集任务和采购任务。 // TaskType 区分采集任务和采购任务。
+12
View File
@@ -50,3 +50,15 @@ func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
} }
return nil 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
}
+71 -3
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec" "cmautobuy/admin/spec"
) )
const mysqlSchemaVersion = 3 const mysqlSchemaVersion = 4
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { 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) KEY idx_spec_mappings_pdd (pdd_goods_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` ) 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。生产迁移只能在这里追加新版本。 // MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
func MigrateMySQL(db *sql.DB) error { func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( 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 { if err := migrateMySQLV3(db); err != nil {
return fmt.Errorf("执行 MySQL schema v3 失败: %w", err) 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) return fmt.Errorf("MySQL schema v3 自检失败,未记录版本: %w", err)
} }
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { 3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v3 失败: %w", err) 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) 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 改为顺运宝商品规格原文。 // migrateMySQLV3 把采购规格身份从蝦皮 SKU 改为顺运宝商品规格原文。
// 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。 // 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。
func migrateMySQLV3(db *sql.DB) error { 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", "shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings",
"tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state",
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock", "users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
"spec_mapping_decisions",
} }
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil { if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
return err 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 { func checkMySQLSchema(db *sql.DB, tables []string) error {
@@ -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 { func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
+6 -4
View File
@@ -346,6 +346,7 @@ type SybOrderContext struct {
PddCollectStatus string PddCollectStatus string
PddCollectMsg string PddCollectMsg string
PddSkusJSON string PddSkusJSON string
PddUpdatedAt string
MappingOptionKey string MappingOptionKey string
MappingOptions string MappingOptions string
HasActiveTask bool HasActiveTask bool
@@ -356,14 +357,14 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var priceCent sql.NullInt64 var priceCent sql.NullInt64
var shopeeExists int 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 mappingKey, mappingOptions sql.NullString
var hasActiveTask int var hasActiveTask int
err := s.Scan( err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID, &c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData, &c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists, &c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt,
&mappingKey, &mappingOptions, &hasActiveTask, &mappingKey, &mappingOptions, &hasActiveTask,
) )
c.Order.Title = title.String c.Order.Title = title.String
@@ -378,6 +379,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
c.PddCollectStatus = collectStatus.String c.PddCollectStatus = collectStatus.String
c.PddCollectMsg = collectMsg.String c.PddCollectMsg = collectMsg.String
c.PddSkusJSON = skusJSON.String c.PddSkusJSON = skusJSON.String
c.PddUpdatedAt = pddUpdatedAt.String
c.MappingOptionKey = mappingKey.String c.MappingOptionKey = mappingKey.String
c.MappingOptions = mappingOptions.String c.MappingOptions = mappingOptions.String
c.HasActiveTask = hasActiveTask != 0 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, 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, 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, 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' EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed'))` + 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, 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, 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, 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' EXISTS(SELECT 1 FROM tasks t WHERE t.task_type = 'purchase'
AND t.syb_id = so.syb_id AND t.syb_id = so.syb_id
AND t.status IN ('pending', 'assigned', 'claimed'))`+ AND t.status IN ('pending', 'assigned', 'claimed'))`+
+35 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"math" "math"
"strings" "strings"
"unicode/utf8"
"cmautobuy/admin/model" "cmautobuy/admin/model"
"cmautobuy/admin/repository" "cmautobuy/admin/repository"
@@ -20,12 +21,16 @@ type PddOptionChoice struct {
PriceCent int64 PriceCent int64
HasPrice bool HasPrice bool
Selected 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) collected, err := parseCollected(raw)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
keys, names := dimensionOrder(collected) keys, names := dimensionOrder(collected)
choices := make([]PddOptionChoice, 0, len(collected.SKUs)) choices := make([]PddOptionChoice, 0, len(collected.SKUs))
@@ -36,7 +41,7 @@ func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) {
} }
key, err := OptionKey(sku.Options) key, err := OptionKey(sku.Options)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
if seen[key] { if seen[key] {
continue continue
@@ -52,18 +57,18 @@ func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) {
} }
choice := PddOptionChoice{ choice := PddOptionChoice{
Key: key, OptionsJSON: key, Label: strings.Join(parts, " / "), 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 { if sku.PriceCent != nil && *sku.PriceCent > 0 {
choice.PriceCent, choice.HasPrice = *sku.PriceCent, true choice.PriceCent, choice.HasPrice = *sku.PriceCent, true
} }
choices = append(choices, choice) choices = append(choices, choice)
} }
return choices, names, nil return choices, keys, names, nil
} }
func findPddChoice(raw, key string) (*PddOptionChoice, error) { func findPddChoice(raw, key string) (*PddOptionChoice, error) {
choices, _, err := pddOptionChoices(raw) choices, _, _, err := pddOptionChoices(raw)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -77,7 +82,7 @@ func findPddChoice(raw, key string) (*PddOptionChoice, error) {
} }
// SaveSybMapping 保存顺运宝商品规格到当前 PDD 商品的可复用映射。 // 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() tx, err := db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("开始保存规格映射事务失败: %w", err) return fmt.Errorf("开始保存规格映射事务失败: %w", err)
@@ -90,6 +95,13 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
if context == nil { if context == nil {
return fmt.Errorf("顺运宝明细不存在") 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) key, err := spec.SpecKey(context.Order.ProductSpec)
if err != nil || context.Order.SpecKey == "" { if err != nil || context.Order.SpecKey == "" {
return fmt.Errorf("顺运宝未提供有效规格,不能保存映射") return fmt.Errorf("顺运宝未提供有效规格,不能保存映射")
@@ -108,6 +120,14 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
if choice == nil { if choice == nil {
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试") 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{ if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID, SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
@@ -116,6 +136,14 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
}); err != nil { }); err != nil {
return err 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() return tx.Commit()
} }
+76
View File
@@ -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(&current)
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 { 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}]}` 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}]}`
} }
+264
View File
@@ -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, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。"
}
+74
View File
@@ -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)
}
}
+17 -3
View File
@@ -12,6 +12,7 @@ package service
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -28,6 +29,13 @@ import (
"cmautobuy/admin/syb" "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 ( const (
dateLayout = "2006-01-02" dateLayout = "2006-01-02"
maxSpecifiedSyncDays = 31 maxSpecifiedSyncDays = 31
@@ -1155,6 +1163,8 @@ type SybProcessingDetail struct {
CanCollect bool CanCollect bool
PddDimensionNames []string PddDimensionNames []string
PddChoices []PddOptionChoice PddChoices []PddOptionChoice
ContextVersion string
RecommendationNotice string
MappingValid bool MappingValid bool
HasActiveTask bool HasActiveTask bool
} }
@@ -1177,18 +1187,22 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
context.PddCollectStatus == string(model.CollectFailed)) context.PddCollectStatus == string(model.CollectFailed))
d.HasActiveTask = context.HasActiveTask d.HasActiveTask = context.HasActiveTask
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" { if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
choices, names, parseErr := pddOptionChoices(context.PddSkusJSON) choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
if parseErr == nil { if parseErr == nil {
d.PddDimensionNames = names 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 { 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 { if d.PddChoices[i].Selected {
d.MappingValid = true d.MappingValid = true
} }
} }
} }
} }
d.ContextVersion = mappingContextVersion(*context)
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context) d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
return d, nil return d, nil
} }
+5 -2
View File
@@ -57,6 +57,7 @@
<form method="post" action="/syb/match" class="form-stack"> <form method="post" action="/syb/match" class="form-stack">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="syb_id" value="{{.SybID}}"> <input type="hidden" name="syb_id" value="{{.SybID}}">
<input type="hidden" name="context_version" value="{{.ContextVersion}}">
<input type="hidden" name="order_no" value="{{$.Keyword}}"> <input type="hidden" name="order_no" value="{{$.Keyword}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}"> <input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
@@ -65,13 +66,15 @@
<select id="syb-pdd-option" name="pdd_option_key" required> <select id="syb-pdd-option" name="pdd_option_key" required>
<option value="">请选择,不做相似规格猜测</option> <option value="">请选择,不做相似规格猜测</option>
{{range .PddChoices}} {{range .PddChoices}}
<option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{.Label}} · {{.PriceText}}</option> <option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{if .Recommended}}★ 建议 · {{end}}{{.Label}} · {{.PriceText}}</option>
{{end}} {{end}}
</select> </select>
{{range .PddChoices}}{{if .Recommended}}<small>推荐理由:{{.RecommendationReason}}</small>{{end}}{{end}}
{{if .RecommendationNotice}}<small role="status">{{.RecommendationNotice}}</small>{{end}}
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮商品 + 顺运宝规格 + 当前 PDD 商品会复用。</small> <small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮商品 + 顺运宝规格 + 当前 PDD 商品会复用。</small>
</div> </div>
{{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}} {{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}}
<div><button type="submit" class="primary">保存规格映射</button></div> <div><button type="submit" class="primary">保存规格映射</button> <small>请核对颜色、尺码和体重范围。</small></div>
</form> </form>
{{else}} {{else}}
<p class="missing">最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。</p> <p class="missing">最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。</p>
+2
View File
@@ -219,6 +219,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
下次遇到同商品、同规格和同 PDD 商品时自动带出。 下次遇到同商品、同规格和同 PDD 商品时自动带出。
- `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口, - `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口,
而不是显示一个空列表让人困惑。 而不是显示一个空列表让人困惑。
- 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。
- 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。
**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗要求 **创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗要求
选择当前账号可见的客户端,并逐行确认人民币价格上限;服务端仍按 §5 全量复核。 选择当前账号可见的客户端,并逐行确认人民币价格上限;服务端仍按 §5 全量复核。
+32
View File
@@ -599,6 +599,38 @@ OptionKey(map[string]string{"size": "M", "color": "黑色"})
只要有一点点不同(空格、转义、键序),映射就会静默对不上。 只要有一点点不同(空格、转义、键序),映射就会静默对不上。
**Client 只上报 `options` 对象,key 一律由 Admin 这一个函数算。** **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` 任务 ## 7. `tasks` 任务
采集任务和采购任务共用一张表,用 `task_type` 区分。 采集任务和采购任务共用一张表,用 `task_type` 区分。
+3
View File
@@ -519,6 +519,9 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。 - 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。 - 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
- 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。 - 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。
- 候选按确定性 A/B/C/冲突规则稳定排序,界面不显示数字分数。只有唯一 A 级且无额外维度歧义时预选,但仍必须人工点“保存”。
- `★ 建议` 同时显示码位、体重覆盖和主色等文字理由;保存按钮旁固定提示“请核对颜色、尺码和体重范围”。
- 弹窗携带完整匹配上下文版本;顺运宝明细、PDD 关联/采集结果或规则版本改变后拒绝陈旧保存。
### 6.4 创建采购任务 ### 6.4 创建采购任务