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