diff --git a/admin/repository/mapping.go b/admin/repository/mapping.go index 8dcff59..0eb6621 100644 --- a/admin/repository/mapping.go +++ b/admin/repository/mapping.go @@ -77,7 +77,14 @@ func UpsertSpecMapping(q Execer, m model.SpecMapping) error { // UpsertAutomaticSpecMapping 只在没有人工映射且上下文版本变化时写入自动结果。 // 同一上下文的并发 AI 请求由先写入者获胜;人工保存无论先后都不会被自动结果覆盖。 func UpsertAutomaticSpecMapping(q Execer, m model.SpecMapping) error { - _, err := q.Exec(` + _, err := UpsertAutomaticSpecMappingChanged(q, m) + return err +} + +// UpsertAutomaticSpecMappingChanged 与 UpsertAutomaticSpecMapping 使用同一条并发安全 SQL, +// 并返回本次是否真正插入或更新。调用方据此避免为幂等重放重复追加决策审计。 +func UpsertAutomaticSpecMappingChanged(q Execer, m model.SpecMapping) (bool, error) { + result, err := q.Exec(` INSERT INTO spec_mappings (shopee_goods_id,spec_key,pdd_goods_id,pdd_option_key,pdd_options,spec_raw, mapped_at,mapped_by,source,source_provider_id,source_model,confidence_bps, @@ -101,9 +108,30 @@ func UpsertAutomaticSpecMapping(q Execer, m model.SpecMapping) error { nullableText(m.SourceModel), nullableInt(m.ConfidenceBPS, m.ConfidenceSet), nullableText(m.SourceReason), nullableText(m.SourceVersion), nullableText(m.ContextVersion)) if err != nil { - return fmt.Errorf("保存自动规格映射失败: %w", err) + return false, fmt.Errorf("保存自动规格映射失败: %w", err) } - return nil + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("读取自动规格映射保存结果失败: %w", err) + } + return rows > 0, nil +} + +// DeleteAutomaticSpecMappingByVersion 只删除指定规则自己生成的映射。 +// 人工和 AI 结果以及其他规则版本永远不受影响。 +func DeleteAutomaticSpecMappingByVersion(q Execer, shopeeGoodsID, specKey, pddGoodsID, sourceVersion string) (bool, error) { + result, err := q.Exec(`DELETE FROM spec_mappings + WHERE shopee_goods_id=? AND spec_key=? AND pdd_goods_id=? + AND source='rule' AND source_version=?`, + shopeeGoodsID, specKey, pddGoodsID, sourceVersion) + if err != nil { + return false, fmt.Errorf("清除失效的颜色辅助规格映射失败: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("读取颜色辅助规格映射清除结果失败: %w", err) + } + return rows > 0, nil } func InsertAISpecMatchDecision(q Execer, d model.AISpecMatchDecision) error { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 563f340..0e03bbe 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -332,6 +332,25 @@ func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]strin return links, nil } +// ListShopeeGoodsIDsByPddGoodsID 返回当前仍关联指定 PDD 商品的蝦皮商品。 +func ListShopeeGoodsIDsByPddGoodsID(q Execer, pddGoodsID string) ([]string, error) { + rows, err := q.Query(`SELECT goods_id FROM shopee_products + WHERE pdd_goods_id=? AND deleted_at IS NULL ORDER BY goods_id`, pddGoodsID) + if err != nil { + return nil, fmt.Errorf("查询 PDD 商品 %s 的蝦皮关联失败: %w", pddGoodsID, err) + } + defer rows.Close() + var result []string + for rows.Next() { + var goodsID string + if err := rows.Scan(&goodsID); err != nil { + return nil, fmt.Errorf("读取 PDD 商品的蝦皮关联失败: %w", err) + } + result = append(result, goodsID) + } + return result, rows.Err() +} + // UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。 // 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。 func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error { diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 4d54f60..18eb185 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -726,6 +726,26 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) { return &row, nil } +// ListSybIDsByShopeeGoodsID 限定读取一个蝦皮商品已有的有效规格明细,供写入事件幂等派生。 +func ListSybIDsByShopeeGoodsID(q Execer, goodsID string) ([]string, error) { + rows, err := q.Query(`SELECT syb_id FROM syb_orders + WHERE shopee_goods_id=? AND spec_key IS NOT NULL AND spec_key<>'' + ORDER BY syb_id`, goodsID) + if err != nil { + return nil, fmt.Errorf("查询蝦皮商品 %s 的顺运宝明细失败: %w", goodsID, err) + } + defer rows.Close() + var result []string + for rows.Next() { + var sybID string + if err := rows.Scan(&sybID); err != nil { + return nil, fmt.Errorf("读取颜色辅助顺运宝明细失败: %w", err) + } + result = append(result, sybID) + } + return result, rows.Err() +} + // ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。 func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) { where, args := sybOrderFilterClause(filter) diff --git a/admin/service/color_mapping.go b/admin/service/color_mapping.go index 8308145..f41217b 100644 --- a/admin/service/color_mapping.go +++ b/admin/service/color_mapping.go @@ -402,6 +402,9 @@ func SaveProductColorMappings(db *sql.DB, actor *model.User, goodsID, expectedCo } changed++ } + if _, err := DeriveProductColorSpecMappings(tx, context.ShopeeGoodsID); err != nil { + return 0, fmt.Errorf("颜色映射已校验但派生完整规格失败,整批未保存: %w", err) + } if err := tx.Commit(); err != nil { return 0, fmt.Errorf("提交颜色映射事务失败: %w", err) } diff --git a/admin/service/color_spec_derivation.go b/admin/service/color_spec_derivation.go new file mode 100644 index 0000000..6d40645 --- /dev/null +++ b/admin/service/color_spec_derivation.go @@ -0,0 +1,304 @@ +package service + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/spec" +) + +const ( + // ColorAssistedSpecRulesVersion 标识由商品颜色映射辅助生成的完整规格映射。 + ColorAssistedSpecRulesVersion = "color-spec-v1" + colorMappingSystemActor = "system:color-mapping" +) + +// ColorSpecDerivation 是一次幂等派生的业务结论。没有生成不是系统错误。 +type ColorSpecDerivation struct { + Outcome, Reason, OptionKey string + Changed bool +} + +// ColorSpecDerivationSummary 是商品级事件处理既有顺运宝明细的汇总。 +type ColorSpecDerivationSummary struct { + Processed, Generated, Removed, Preserved, Skipped int +} + +// DeriveSybSpecMappingFromProductColor 尝试为一条顺运宝明细生成完整 PDD 规格映射。 +// q 应当是触发写入事件所在的事务,数据库错误由调用方回滚该事务。 +func DeriveSybSpecMappingFromProductColor(q repository.Execer, sybID string) (ColorSpecDerivation, error) { + context, err := repository.GetSybOrderContext(q, strings.TrimSpace(sybID)) + if err != nil { + return ColorSpecDerivation{}, err + } + if context == nil { + return ColorSpecDerivation{Outcome: "skipped", Reason: "顺运宝明细不存在"}, nil + } + existing, err := repository.GetSpecMapping(q, context.Order.ShopeeGoodsID, context.Order.SpecKey, context.PddGoodsID) + if err != nil { + return ColorSpecDerivation{}, err + } + if existing != nil && existing.Source == "manual" { + return ColorSpecDerivation{Outcome: "preserved", Reason: "已有人工完整规格映射", OptionKey: existing.PddOptionKey}, nil + } + removeOwnMapping := func(reason string) (ColorSpecDerivation, error) { + removed, removeErr := repository.DeleteAutomaticSpecMappingByVersion(q, + context.Order.ShopeeGoodsID, context.Order.SpecKey, context.PddGoodsID, + ColorAssistedSpecRulesVersion) + if removeErr != nil { + return ColorSpecDerivation{}, removeErr + } + outcome := "skipped" + if removed { + outcome = "removed" + } + return ColorSpecDerivation{Outcome: outcome, Reason: reason, Changed: removed}, nil + } + + if context.Order.SpecKey == "" || context.Order.ShopeeGoodsID == "" { + return removeOwnMapping("顺运宝规格或蝦皮商品 ID 缺失") + } + parsed, ok := spec.ParseShopeeSpec(context.Order.ProductSpec) + if !ok { + return removeOwnMapping("顺运宝规格不能可靠解析出颜色和尺码") + } + if key, keyErr := spec.SpecKey(context.Order.ProductSpec); keyErr != nil || key != context.Order.SpecKey { + return ColorSpecDerivation{}, fmt.Errorf("顺运宝规格身份键不一致") + } + if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) || + strings.TrimSpace(context.PddSkusJSON) == "" { + return removeOwnMapping("当前 PDD 商品未关联或尚未完成采集") + } + + colorKey := NormalizeProductColorKey(parsed.Color) + colorMapping, err := repository.GetProductColorMapping(q, context.Order.ShopeeGoodsID, colorKey, context.PddGoodsID) + if err != nil { + return ColorSpecDerivation{}, err + } + if colorMapping == nil { + return removeOwnMapping("当前蝦皮颜色没有商品级 PDD 颜色映射") + } + dimensionKey, colorCandidates, candidateErr := aggregatePddColorCandidates(context.PddSkusJSON) + if candidateErr != nil { + return removeOwnMapping(candidateErr.Error()) + } + if colorMapping.PddDimensionKey != dimensionKey || !containsPddColorCandidate(colorCandidates, colorMapping.PddColorValue) { + return removeOwnMapping("商品颜色映射目标已不在当前可购买候选中") + } + choices, keys, names, err := pddOptionChoices(context.PddSkusJSON) + if err != nil { + return ColorSpecDerivation{}, fmt.Errorf("读取 PDD 完整规格失败: %w", err) + } + matched, reason := uniqueColorAssistedChoice(context.Order.ProductSpec, parsed, colorMapping, choices, keys, names) + if matched == nil { + return removeOwnMapping(reason) + } + if current := choiceByKey(choices, matched.Key); current.Key == "" { + return removeOwnMapping("唯一候选在写入前已不属于当前可购买组合") + } + if existing != nil && !(existing.Source == "rule" && existing.SourceVersion == ColorAssistedSpecRulesVersion) { + if current, findErr := findPddChoice(context.PddSkusJSON, existing.PddOptionKey); findErr != nil { + return ColorSpecDerivation{}, findErr + } else if current != nil { + return ColorSpecDerivation{Outcome: "preserved", Reason: "已有其他来源的有效完整规格映射", OptionKey: existing.PddOptionKey}, nil + } + } + version := colorAssistedSpecContextVersion(*context, *colorMapping) + if existing != nil && existing.Source == "rule" && existing.SourceVersion == ColorAssistedSpecRulesVersion && + existing.ContextVersion == version && existing.PddOptionKey == matched.Key { + return ColorSpecDerivation{Outcome: "unchanged", Reason: "相同上下文已经生成完整规格映射", OptionKey: matched.Key}, nil + } + now := model.NowISO() + reasonText := fmt.Sprintf("商品颜色映射“%s”→“%s”后,剩余规格维度唯一命中当前可购买组合", + truncateRunes(parsed.Color, 120), truncateRunes(colorMapping.PddColorValue, 120)) + changed, err := repository.UpsertAutomaticSpecMappingChanged(q, model.SpecMapping{ + ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: context.Order.SpecKey, + SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID, + PddOptionKey: matched.Key, PddOptions: matched.OptionsJSON, + MappedAt: now, MappedBy: colorMappingSystemActor, Source: "rule", + SourceReason: reasonText, SourceVersion: ColorAssistedSpecRulesVersion, + ContextVersion: version, + }) + if err != nil { + return ColorSpecDerivation{}, err + } + if changed { + if err := repository.InsertSpecMappingDecision(q, model.SpecMappingDecision{ + ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: context.Order.SpecKey, + PddGoodsID: context.PddGoodsID, RulesVersion: ColorAssistedSpecRulesVersion, + SuggestedOptionKey: matched.Key, ChosenOptionKey: matched.Key, Accepted: true, + DecidedBy: colorMappingSystemActor, DecidedAt: now, + }); err != nil { + return ColorSpecDerivation{}, err + } + } + return ColorSpecDerivation{Outcome: "generated", Reason: reasonText, OptionKey: matched.Key, Changed: changed}, nil +} + +// DeriveProductColorSpecMappings 只扫描指定蝦皮商品已有的有效规格明细。 +func DeriveProductColorSpecMappings(q repository.Execer, goodsID string) (ColorSpecDerivationSummary, error) { + var summary ColorSpecDerivationSummary + ids, err := repository.ListSybIDsByShopeeGoodsID(q, strings.TrimSpace(goodsID)) + if err != nil { + return summary, err + } + for _, sybID := range ids { + result, err := DeriveSybSpecMappingFromProductColor(q, sybID) + if err != nil { + return ColorSpecDerivationSummary{}, err + } + summary.Processed++ + switch result.Outcome { + case "generated": + if result.Changed { + summary.Generated++ + } else { + summary.Skipped++ + } + case "removed": + summary.Removed++ + case "preserved": + summary.Preserved++ + default: + summary.Skipped++ + } + } + return summary, nil +} + +// DerivePddColorSpecMappings 处理当前关联某个 PDD 商品的全部蝦皮商品。 +func DerivePddColorSpecMappings(q repository.Execer, pddGoodsID string) (ColorSpecDerivationSummary, error) { + var total ColorSpecDerivationSummary + goodsIDs, err := repository.ListShopeeGoodsIDsByPddGoodsID(q, strings.TrimSpace(pddGoodsID)) + if err != nil { + return total, err + } + for _, goodsID := range goodsIDs { + summary, err := DeriveProductColorSpecMappings(q, goodsID) + if err != nil { + return ColorSpecDerivationSummary{}, err + } + total.Processed += summary.Processed + total.Generated += summary.Generated + total.Removed += summary.Removed + total.Preserved += summary.Preserved + total.Skipped += summary.Skipped + } + return total, nil +} + +func containsPddColorCandidate(candidates []PddColorCandidate, value string) bool { + for _, candidate := range candidates { + if candidate.Value == value { + return true + } + } + return false +} + +func uniqueColorAssistedChoice(raw string, parsed spec.ParsedShopeeSpec, mapping *model.ProductColorMapping, + choices []PddOptionChoice, keys, names []string) (*PddOptionChoice, string) { + filtered := make([]PddOptionChoice, 0, len(choices)) + for _, choice := range choices { + if choice.Options[mapping.PddDimensionKey] == mapping.PddColorValue { + filtered = append(filtered, choice) + } + } + if len(filtered) == 0 { + return nil, "目标 PDD 颜色没有当前可购买完整规格组合" + } + identifiedColor, sizeKey, ambiguous, _ := identifyDimensions(choices, keys, names) + if ambiguous || (identifiedColor != "" && identifiedColor != mapping.PddDimensionKey) { + return nil, "PDD 颜色或尺码维度定义不明确" + } + if sizeKey != "" { + sourceFeatures := extractSpecFeatures(parsed.Size + " " + parsed.Advice) + next := make([]PddOptionChoice, 0, len(filtered)) + for _, choice := range filtered { + if deterministicSizeMatch(sourceFeatures, extractSpecFeatures(choice.Options[sizeKey]), parsed.Size, choice.Options[sizeKey]) { + next = append(next, choice) + } + } + filtered = next + if len(filtered) == 0 { + return nil, "目标 PDD 颜色下没有确定匹配的尺码" + } + } + for _, key := range keys { + if key == mapping.PddDimensionKey || key == sizeKey { + continue + } + values := map[string]bool{} + for _, choice := range filtered { + values[choice.Options[key]] = true + } + if len(values) <= 1 { + continue + } + var matches []string + normalizedRaw := normalizeDimensionSignal(raw) + for value := range values { + normalizedValue := normalizeDimensionSignal(value) + if normalizedValue != "" && strings.Contains(normalizedRaw, normalizedValue) { + matches = append(matches, value) + } + } + sort.Strings(matches) + if len(matches) != 1 { + return nil, "存在无法从顺运宝规格唯一确定的额外规格维度" + } + next := make([]PddOptionChoice, 0, len(filtered)) + for _, choice := range filtered { + if choice.Options[key] == matches[0] { + next = append(next, choice) + } + } + filtered = next + } + if len(filtered) != 1 { + return nil, fmt.Sprintf("颜色和其余规格条件仍命中 %d 个可购买组合", len(filtered)) + } + choice := filtered[0] + return &choice, "" +} + +func deterministicSizeMatch(source, candidate specFeatures, sourceRaw, candidateRaw string) bool { + if source.size != "" && candidate.size != "" { + if source.size != candidate.size { + return false + } + if source.hasWeight && candidate.hasWeight { + return deterministicWeightContained(source, candidate) + } + return true + } + if source.hasWeight && candidate.hasWeight { + return deterministicWeightContained(source, candidate) + } + return normalizeDimensionSignal(sourceRaw) == normalizeDimensionSignal(candidateRaw) +} + +func deterministicWeightContained(source, candidate specFeatures) bool { + if source.weightFrom < candidate.weightFrom || source.weightTo > candidate.weightTo { + return false + } + width := candidate.weightTo - candidate.weightFrom + target := source.weightTo - source.weightFrom + midpoint := math.Abs((source.weightFrom + source.weightTo - candidate.weightFrom - candidate.weightTo) / 2) + return width <= math.Max(target*maxCandidateWidthRatio, minCandidateWidthAllowanceJin) && midpoint <= maxMidpointDistanceJin +} + +func colorAssistedSpecContextVersion(context repository.SybOrderContext, mapping model.ProductColorMapping) string { + payload, _ := json.Marshal([]string{ + mappingContextVersion(context), mapping.ShopeeColorKey, mapping.PddDimensionKey, + mapping.PddColorValue, mapping.MappedAt, ProductColorMappingRulesVersion, + ColorAssistedSpecRulesVersion, + }) + return fmt.Sprintf("%x", sha256.Sum256(payload)) +} diff --git a/admin/service/color_spec_derivation_test.go b/admin/service/color_spec_derivation_test.go new file mode 100644 index 0000000..e05c807 --- /dev/null +++ b/admin/service/color_spec_derivation_test.go @@ -0,0 +1,141 @@ +package service + +import ( + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" + "cmautobuy/admin/spec" +) + +func colorChoice(t *testing.T, options map[string]string) PddOptionChoice { + t.Helper() + key, err := OptionKey(options) + if err != nil { + t.Fatal(err) + } + return PddOptionChoice{Key: key, OptionsJSON: key, Options: options} +} + +func TestUniqueColorAssistedChoice_颜色约束后尺码唯一才生成(t *testing.T) { + parsed, ok := spec.ParseShopeeSpec("黑色,M") + if !ok { + t.Fatal("测试规格应可解析") + } + mapping := &model.ProductColorMapping{PddDimensionKey: "color", PddColorValue: "曜石黑"} + choices := []PddOptionChoice{ + colorChoice(t, map[string]string{"color": "曜石黑", "size": "M码"}), + colorChoice(t, map[string]string{"color": "曜石黑", "size": "L码"}), + colorChoice(t, map[string]string{"color": "奶油白", "size": "M码"}), + } + choice, reason := uniqueColorAssistedChoice("黑色,M", parsed, mapping, choices, + []string{"color", "size"}, []string{"颜色分类", "尺码"}) + if choice == nil || choice.Options["color"] != "曜石黑" || choice.Options["size"] != "M码" || reason != "" { + t.Fatalf("唯一匹配错误 choice=%+v reason=%q", choice, reason) + } + + choices = append(choices, colorChoice(t, map[string]string{"color": "曜石黑", "size": "M码", "style": "加绒"})) + choices[0].Options["style"] = "常规" + choices[0].Key, _ = OptionKey(choices[0].Options) + choices[0].OptionsJSON = choices[0].Key + if got, reason := uniqueColorAssistedChoice("黑色,M", parsed, mapping, choices, + []string{"color", "size", "style"}, []string{"颜色分类", "尺码", "款式"}); got != nil || reason == "" { + t.Fatalf("第三维度不唯一时不应生成 got=%+v reason=%q", got, reason) + } +} + +func TestSaveProductColorMappings_事件生成清除完整规格且保护人工结果(t *testing.T) { + db := newTestDB(t) + seedShopeeProduct(t, db, "S-DERIVE", "颜色派生商品") + seedShopeeSKU(t, db, "SKU-DERIVE", "S-DERIVE", "黑色,M", "黑色", "M", "", true) + if _, err := repository.EnsurePddProduct(db, "P-DERIVE", "https://mobile.yangkeduo.com/goods.html?goods_id=P-DERIVE"); err != nil { + t.Fatal(err) + } + collected := `{"dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],"skus":[` + + `{"options":{"color":"曜石黑","size":"M码"},"price_cent":1180,"available":true},` + + `{"options":{"color":"曜石黑","size":"L码"},"price_cent":1280,"available":true}]}` + if err := repository.SetCollectResult(db, "P-DERIVE", "PDD 商品", "店铺", collected); err != nil { + t.Fatal(err) + } + setShopeePddLink(t, db, "S-DERIVE", "P-DERIVE", "https://mobile.yangkeduo.com/goods.html?goods_id=P-DERIVE") + order := seedWorkflowOrder(t, db, "SYB-DERIVE", "S-DERIVE", "黑色,M") + specKey, _ := spec.SpecKey(order.ProductSpec) + context, err := GetProductColorMappingContext(db, "S-DERIVE") + if err != nil { + t.Fatal(err) + } + actor := &model.User{UserID: "U-DERIVE", Status: model.UserActive} + if changed, err := SaveProductColorMappings(db, actor, "S-DERIVE", context.ContextVersion, + []ProductColorMappingUpdate{{ShopeeColorKey: "黑色", TargetValue: "曜石黑"}}); err != nil || changed != 1 { + t.Fatalf("颜色保存应触发派生 changed=%d err=%v", changed, err) + } + mapping, err := repository.GetSpecMapping(db, "S-DERIVE", specKey, "P-DERIVE") + if err != nil || mapping == nil || mapping.Source != "rule" || mapping.SourceVersion != ColorAssistedSpecRulesVersion { + t.Fatalf("完整规则映射未生成 mapping=%+v err=%v", mapping, err) + } + firstKey := mapping.PddOptionKey + if result, err := DeriveSybSpecMappingFromProductColor(db, "SYB-DERIVE"); err != nil || result.Changed { + t.Fatalf("重复事件必须幂等 result=%+v err=%v", result, err) + } + var decisions int + if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mapping_decisions + WHERE shopee_goods_id='S-DERIVE' AND rules_version=?`, ColorAssistedSpecRulesVersion).Scan(&decisions); err != nil || decisions != 1 { + t.Fatalf("重复事件不应重复审计 decisions=%d err=%v", decisions, err) + } + + context, _ = GetProductColorMappingContext(db, "S-DERIVE") + if _, err := SaveProductColorMappings(db, actor, "S-DERIVE", context.ContextVersion, + []ProductColorMappingUpdate{{ShopeeColorKey: "黑色"}}); err != nil { + t.Fatal(err) + } + mapping, _ = repository.GetSpecMapping(db, "S-DERIVE", specKey, "P-DERIVE") + if mapping != nil { + t.Fatalf("清除颜色映射后应移除本规则完整映射:%+v", mapping) + } + + context, _ = GetProductColorMappingContext(db, "S-DERIVE") + if _, err := SaveProductColorMappings(db, actor, "S-DERIVE", context.ContextVersion, + []ProductColorMappingUpdate{{ShopeeColorKey: "黑色", TargetValue: "曜石黑"}}); err != nil { + t.Fatal(err) + } + manualKey, _ := OptionKey(map[string]string{"color": "曜石黑", "size": "L码"}) + if err := repository.UpsertSpecMapping(db, model.SpecMapping{ + ShopeeGoodsID: "S-DERIVE", SpecKey: specKey, SpecRaw: order.ProductSpec, + PddGoodsID: "P-DERIVE", PddOptionKey: manualKey, PddOptions: manualKey, + Source: "manual", MappedBy: "U-MANUAL", MappedAt: model.NowISO(), + }); err != nil { + t.Fatal(err) + } + if _, err := DeriveSybSpecMappingFromProductColor(db, "SYB-DERIVE"); err != nil { + t.Fatal(err) + } + mapping, _ = repository.GetSpecMapping(db, "S-DERIVE", specKey, "P-DERIVE") + if mapping == nil || mapping.Source != "manual" || mapping.PddOptionKey != manualKey || mapping.PddOptionKey == firstKey { + t.Fatalf("人工完整映射被规则覆盖:%+v", mapping) + } +} + +func TestSubmitResult_Pdd采集写入事件派生完整规格(t *testing.T) { + db := newTestDB(t) + seedWorkflowOrder(t, db, "SYB-COLLECT-HOOK", "SHOPEE-1", "黑色,M") + insertPddProduct(t, db, "PDD-1") + if err := repository.UpdateShopeePddLink(db, "SHOPEE-1", "PDD-1", "https://mobile.yangkeduo.com/goods.html?goods_id=PDD-1"); err != nil { + t.Fatal(err) + } + if err := repository.UpsertProductColorMapping(db, model.ProductColorMapping{ + ShopeeGoodsID: "SHOPEE-1", ShopeeColorKey: "黑色", ShopeeColorRaw: "黑色", + PddGoodsID: "PDD-1", PddDimensionKey: "color", PddColorValue: "黑色", + ContextVersion: "ctx", MappedBy: "U-1", MappedAt: model.NowISO(), + }); err != nil { + t.Fatal(err) + } + insertCollectTask(t, db, "TASK-COLOR-HOOK", "client-001", "SHOPEE-1", "PDD-1") + claimTask(t, db, "TASK-COLOR-HOOK", "client-001") + if _, err := SubmitResult(db, "TASK-COLOR-HOOK", "client-001", "key-color-hook", []byte(collectBody)); err != nil { + t.Fatal(err) + } + context, err := repository.GetSybOrderContext(db, "SYB-COLLECT-HOOK") + if err != nil || context == nil || context.MappingOptionKey == "" || context.MappingSource != "rule" { + t.Fatalf("PDD 采集事件没有派生完整规格 context=%+v err=%v", context, err) + } +} diff --git a/admin/service/submit.go b/admin/service/submit.go index 1c06d55..1bb91c6 100644 --- a/admin/service/submit.go +++ b/admin/service/submit.go @@ -163,9 +163,14 @@ func SubmitResult(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte) "未采集到任何规格,请检查商品是否已下架", ""); err != nil { return nil, err } - } else if err := repository.SetCollectResult( - tx, info.PddGoodsID, collected.Title, collected.ShopName, pddData); err != nil { - return nil, err + } else { + if err := repository.SetCollectResult( + tx, info.PddGoodsID, collected.Title, collected.ShopName, pddData); err != nil { + return nil, err + } + if _, err := DerivePddColorSpecMappings(tx, info.PddGoodsID); err != nil { + return nil, fmt.Errorf("PDD 采集结果已校验但派生顺运宝完整规格失败: %w", err) + } } } return map[string]any{ diff --git a/admin/service/syb.go b/admin/service/syb.go index e24ad21..5ba074b 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -911,6 +911,9 @@ func writeStockDetail(db *sql.DB, baseURL, shopID string, stockRow syb.StockRow, if _, err := repository.UpsertSybShopeeSKU(tx, order.ShopeeGoodsID, order.ProductSpec, model.NowISO()); err != nil { return fmt.Errorf("写入顺运宝规格观测失败: %w", err) } + if _, err := DeriveSybSpecMappingFromProductColor(tx, sybID); err != nil { + return fmt.Errorf("根据商品颜色映射派生顺运宝完整规格失败: %w", err) + } if created { report.Created++ } else { diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index d2e8d7c..90604f7 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -265,6 +265,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 而不是显示一个空列表让人困惑。 - 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。 - 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。 +- 商品级颜色映射是完整规格解析的一个确定性输入,不是采购旁路。顺运宝规格能可靠解析出 + 颜色和尺码时,系统用当前商品颜色映射先限定 PDD 颜色,再确定尺码和其他维度;只有最终 + 剩余一个当前可购买完整组合才以规则来源写入 `spec_mappings`。零命中、多命中、额外维度 + 不明确或颜色目标失效都保持待匹配,不自动创建采购任务。 +- 颜色映射保存、顺运宝明细写入和 PDD 采集结果更新都在各自事务中调用同一个幂等派生服务。 + 人工完整映射永远保留;其他来源仍有效的完整映射不被颜色规则替换。规则自己的依据失效时 + 清除当前派生结果,使现有处理阶段自然回到待匹配。 **创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗列出 当前账号可见的全部客户端都可选择,暂时离线或尚未就绪也可提前指派并等待其就绪后领取。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 7db7e62..e7bc5e3 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -1183,3 +1183,12 @@ CREATE TABLE purchase_spec_resolutions ( `product_color_mapping_audits` 是只追加审计表,记录 `upsert/clear` 的旧值、新值、操作账号、 上下文版本和时间。清除操作只删除当前态,不删除审计;两张表都不保存提示词、模型响应或凭据。 + +颜色映射辅助完整规格时不新增第三张派生表。唯一结果继续写入既有 `spec_mappings`: +`source='rule'`、`source_version='color-spec-v1'`,`source_reason` 记录颜色映射依据, +`context_version` 覆盖顺运宝规格、当前 PDD 快照、颜色目标和规则版本;同时向既有 +`spec_mapping_decisions` 追加成功决策。重复处理同一上下文不重复更新或追加审计。 + +只有本规则自己生成的映射会在颜色依据缺失、目标失效或剩余维度不唯一时被移除;人工映射 +和其他来源的有效完整映射不覆盖、不删除。处理阶段和采购创建仍只认当前 PDD 商品下存在且 +选项仍可购买的完整 `spec_mappings`,颜色表本身不提供可采购状态。