feat: 复用颜色映射生成完整采购规格 (#295)

This commit is contained in:
chengma
2026-08-24 00:12:36 +08:00
parent 3f17bedf6a
commit 486a374869
10 changed files with 545 additions and 6 deletions
+31 -3
View File
@@ -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 {
+19
View File
@@ -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 {
+20
View File
@@ -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)
+3
View File
@@ -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)
}
+304
View File
@@ -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))
}
+141
View File
@@ -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)
}
}
+8 -3
View File
@@ -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{
+3
View File
@@ -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 {
+7
View File
@@ -265,6 +265,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
而不是显示一个空列表让人困惑。
- 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。
- 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。
- 商品级颜色映射是完整规格解析的一个确定性输入,不是采购旁路。顺运宝规格能可靠解析出
颜色和尺码时,系统用当前商品颜色映射先限定 PDD 颜色,再确定尺码和其他维度;只有最终
剩余一个当前可购买完整组合才以规则来源写入 `spec_mappings`。零命中、多命中、额外维度
不明确或颜色目标失效都保持待匹配,不自动创建采购任务。
- 颜色映射保存、顺运宝明细写入和 PDD 采集结果更新都在各自事务中调用同一个幂等派生服务。
人工完整映射永远保留;其他来源仍有效的完整映射不被颜色规则替换。规则自己的依据失效时
清除当前派生结果,使现有处理阶段自然回到待匹配。
**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗列出
当前账号可见的全部客户端都可选择,暂时离线或尚未就绪也可提前指派并等待其就绪后领取。
+9
View File
@@ -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`,颜色表本身不提供可采购状态。