feat: 增加规格匹配建议与决策审计 (#90)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 区分采集任务和采购任务。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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'))`+
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}]}`
|
||||
}
|
||||
|
||||
@@ -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, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
-22
@@ -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
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<form method="post" action="/syb/match" class="form-stack">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<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="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
@@ -65,13 +66,15 @@
|
||||
<select id="syb-pdd-option" name="pdd_option_key" required>
|
||||
<option value="">请选择,不做相似规格猜测</option>
|
||||
{{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}}
|
||||
</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>
|
||||
</div>
|
||||
{{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>
|
||||
{{else}}
|
||||
<p class="missing">最新采集结果里没有可购买的规格组合,请核对 PDD 商品或重新采集。</p>
|
||||
|
||||
Reference in New Issue
Block a user