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

This commit is contained in:
chengma
2026-08-10 12:41:17 +08:00
parent 5abe81e128
commit 5a77bde534
15 changed files with 680 additions and 46 deletions
+42 -14
View File
@@ -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()
}
+76
View File
@@ -130,6 +130,82 @@ func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
})
}
func TestSybMapping_建议审计追加陈旧保护与事务回滚(t *testing.T) {
db := newTestDB(t)
order := seedWorkflowOrder(t, db, "SYB-AUDIT", "SP-AUDIT", "灰色,L建議53-57公斤")
if _, err := AssociateShopeePdd(db, "SP-AUDIT", pddURLA, false); err != nil {
t.Fatal(err)
}
raw := `{"goods_id":"737116531267","dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(80-115斤)"},"price_cent":3990,"available":true},{"options":{"color":"黑色","size":"L(80-115斤)"},"price_cent":3990,"available":true}]}`
if err := repository.SetCollectResult(db, "737116531267", "PDD", "", raw); err != nil {
t.Fatal(err)
}
detail, err := GetSybProcessingDetail(db, order.SybID)
if err != nil {
t.Fatal(err)
}
var suggested, other string
for _, c := range detail.PddChoices {
if c.Recommended {
suggested = c.Key
} else {
other = c.Key
}
}
if suggested == "" || detail.ContextVersion == "" {
t.Fatalf("无建议: %+v", detail)
}
var unopenedMappings, unopenedDecisions int
db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedMappings)
db.QueryRow(`SELECT COUNT(*) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedDecisions)
if unopenedMappings != 0 || unopenedDecisions != 0 {
t.Fatal("只打开或关闭弹窗不得写库")
}
if err := SaveSybMapping(db, order.SybID, other, "USR", detail.ContextVersion); err != nil {
t.Fatal(err)
}
if err := SaveSybMapping(db, order.SybID, suggested, "USR", mappingContextVersionMust(t, db, order.SybID)); err != nil {
t.Fatal(err)
}
var total, accepted int
db.QueryRow(`SELECT COUNT(*),SUM(accepted) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&total, &accepted)
if total != 2 || accepted != 1 {
t.Fatalf("审计 total=%d accepted=%d", total, accepted)
}
old := mappingContextVersionMust(t, db, order.SybID)
if err := repository.SetCollectResult(db, "737116531267", "PDD2", "", raw+" "); err != nil {
t.Fatal(err)
}
if err := SaveSybMapping(db, order.SybID, suggested, "USR", old); err == nil {
t.Fatal("陈旧弹窗必须拒绝")
}
before := suggested
mustExecService(t, db, `DROP TABLE spec_mapping_decisions`)
if err := SaveSybMapping(db, order.SybID, other, "USR", mappingContextVersionMust(t, db, order.SybID)); err == nil {
t.Fatal("审计失败必须回滚")
}
var current string
db.QueryRow(`SELECT pdd_option_key FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(&current)
if current != before {
t.Fatal("映射未回滚")
}
}
func mappingContextVersionMust(t *testing.T, db *sql.DB, id string) string {
t.Helper()
c, e := repository.GetSybOrderContext(db, id)
if e != nil {
t.Fatal(e)
}
return mappingContextVersion(*c)
}
func mustExecService(t *testing.T, db *sql.DB, q string) {
t.Helper()
if _, e := db.Exec(q); e != nil {
t.Fatal(e)
}
}
func stringsReplaceGoodsID(raw string) string {
return `{"goods_id":"937122477375","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}],"skus":[{"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true}]}`
}
+264
View File
@@ -0,0 +1,264 @@
package service
import (
"fmt"
"math"
"regexp"
"sort"
"strconv"
"strings"
)
const (
SpecMatchRulesVersion = "rules_v1"
maxCandidateWidthRatio = 5.0
minCandidateWidthAllowanceJin = 40.0
maxMidpointDistanceJin = 20.0
)
type matchLevel int
const (
matchConflict matchLevel = iota
matchC
matchB
matchA
)
type specFeatures struct {
size string
weightFrom float64
weightTo float64
hasWeight bool
colors map[string]bool
}
type SpecMatchResult struct {
Choices []PddOptionChoice
SuggestedOptionKey string
PreselectOptionKey string
Notice string
}
var traditionalPairs = []struct{ from, to string }{
{"藍", "蓝"}, {"個", "个"}, {"規", "规"}, {"長", "长"}, {"碼", "码"},
{"綠", "绿"}, {"紅", "红"}, {"淺", "浅"}, {"裝", "装"}, {"條", "条"},
}
var weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*[-~~至到]\s*(\d+(?:\.\d+)?)\s*(公斤|kg|斤)`)
var sizePattern = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])((?:[2-9]xl)|(?:x{2,9}l)|xl|xs|s|m|l|均码|f)(?:码)?(?:$|[^a-z])`)
var colorAliases = []struct {
word string
set []string
}{
{"粉红", []string{"粉"}}, {"浅粉", []string{"粉"}}, {"深粉", []string{"粉"}}, {"粉色", []string{"粉"}},
{"米白", []string{"米", "白"}}, {"藏青", []string{"藏青"}}, {"藏蓝", []string{"藏青"}},
{"卡其", []string{"卡其"}}, {"咖啡", []string{"咖啡"}},
{"黑", []string{"黑"}}, {"白", []string{"白"}}, {"灰", []string{"灰"}}, {"红", []string{"红"}},
{"蓝", []string{"蓝"}}, {"绿", []string{"绿"}}, {"黄", []string{"黄"}}, {"紫", []string{"紫"}},
{"粉", []string{"粉"}}, {"米", []string{"米"}}, {"青", []string{"青"}},
}
func simplifyExplicit(raw string) string {
for _, pair := range traditionalPairs {
raw = strings.ReplaceAll(raw, pair.from, pair.to)
}
return raw
}
func extractSpecFeatures(raw string) specFeatures {
normalized := simplifyExplicit(raw)
f := specFeatures{colors: map[string]bool{}}
if match := weightPattern.FindStringSubmatch(normalized); len(match) > 0 {
f.weightFrom, _ = strconv.ParseFloat(match[1], 64)
f.weightTo, _ = strconv.ParseFloat(match[2], 64)
if strings.EqualFold(match[3], "公斤") || strings.EqualFold(match[3], "kg") {
f.weightFrom *= 2
f.weightTo *= 2
}
if f.weightFrom > f.weightTo {
f.weightFrom, f.weightTo = f.weightTo, f.weightFrom
}
f.hasWeight = true
}
if match := sizePattern.FindStringSubmatch(strings.ToLower(normalized)); len(match) > 0 {
f.size = normalizeSize(match[1])
}
colorText := normalized
for _, alias := range colorAliases {
if strings.Contains(colorText, alias.word) {
for _, value := range alias.set {
f.colors[value] = true
}
colorText = strings.ReplaceAll(colorText, alias.word, strings.Repeat(" ", len([]rune(alias.word))))
}
}
return f
}
func normalizeSize(raw string) string {
if raw == "均码" {
return raw
}
raw = strings.ToUpper(strings.TrimSuffix(raw, "码"))
if strings.HasSuffix(raw, "L") && strings.TrimSuffix(raw, "L") != "" {
prefix := strings.TrimSuffix(raw, "L")
if strings.Trim(prefix, "X") == "" && len(prefix) >= 2 {
return fmt.Sprintf("%dXL", len(prefix))
}
}
return raw
}
type rankedChoice struct {
choice PddOptionChoice
level matchLevel
coverage, iou, midpoint float64
index int
}
func rankSpecChoices(raw string, choices []PddOptionChoice, keys, names []string) SpecMatchResult {
source := extractSpecFeatures(raw)
colorKey, sizeKey, ambiguous, extra := identifyDimensions(choices, keys, names)
ranked := make([]rankedChoice, 0, len(choices))
for i, choice := range choices {
text := choice.Label
if colorKey != "" || sizeKey != "" {
text = choice.Options[colorKey] + " " + choice.Options[sizeKey]
}
candidate := extractSpecFeatures(text)
level, coverage, iou, midpoint, reason := classifySpec(source, candidate)
choice.RecommendationReason = reason
choice.MatchLevel = []string{"冲突", "C", "B", "A"}[level]
ranked = append(ranked, rankedChoice{choice, level, coverage, iou, midpoint, i})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].level != ranked[j].level {
return ranked[i].level > ranked[j].level
}
if ranked[i].coverage != ranked[j].coverage {
return ranked[i].coverage > ranked[j].coverage
}
if ranked[i].iou != ranked[j].iou {
return ranked[i].iou > ranked[j].iou
}
return ranked[i].midpoint < ranked[j].midpoint
})
result := SpecMatchResult{Choices: make([]PddOptionChoice, len(ranked))}
aCount := 0
for i := range ranked {
result.Choices[i] = ranked[i].choice
if ranked[i].level == matchA {
aCount++
}
}
if aCount == 1 {
result.SuggestedOptionKey = result.Choices[0].Key
result.Choices[0].Recommended = true
if !ambiguous && !extra {
result.PreselectOptionKey = result.Choices[0].Key
}
}
if aCount > 1 {
result.Notice = "有多个 A 级候选,已稳定置顶但不会预选。"
}
if ambiguous || extra {
result.Notice = "存在无法判断的额外规格维度,请人工核对;系统不会预选。"
}
return result
}
func identifyDimensions(choices []PddOptionChoice, keys, names []string) (color, size string, ambiguous, extra bool) {
colorAliases := map[string]bool{"color": true, "colour": true, "颜色": true, "颜色分类": true, "色系": true}
sizeAliases := map[string]bool{"size": true, "尺码": true, "尺寸": true, "大小": true, "码数": true}
for i, key := range keys {
name := key
if i < len(names) {
name += " " + names[i]
}
parts := strings.Fields(strings.ToLower(simplifyExplicit(name)))
isColor, isSize := false, false
for _, p := range parts {
if colorAliases[p] {
isColor = true
}
if sizeAliases[p] {
isSize = true
}
}
if isColor {
if color != "" && color != key {
ambiguous = true
}
color = key
}
if isSize {
if size != "" && size != key {
ambiguous = true
}
size = key
}
}
for _, key := range keys {
if key == color || key == size {
continue
}
values := map[string]bool{}
for _, c := range choices {
values[c.Options[key]] = true
}
if len(values) > 1 {
extra = true
}
}
return
}
func classifySpec(a, b specFeatures) (matchLevel, float64, float64, float64, string) {
colorKnown := len(a.colors) > 0 && len(b.colors) > 0
colorHit := false
for c := range a.colors {
if b.colors[c] {
colorHit = true
}
}
if colorKnown && !colorHit {
return matchConflict, 0, 0, math.MaxFloat64, "主色明确冲突,请勿选择。"
}
sizeEqual := a.size != "" && a.size == b.size
coverage, iou, mid := 0.0, 0.0, math.MaxFloat64
if a.hasWeight && b.hasWeight {
inter := math.Max(0, math.Min(a.weightTo, b.weightTo)-math.Max(a.weightFrom, b.weightFrom))
target := a.weightTo - a.weightFrom
union := math.Max(a.weightTo, b.weightTo) - math.Min(a.weightFrom, b.weightFrom)
if target == 0 {
if a.weightFrom >= b.weightFrom && a.weightFrom <= b.weightTo {
coverage = 1
}
} else {
coverage = inter / target
}
if union > 0 {
iou = inter / union
}
mid = math.Abs((a.weightFrom + a.weightTo - b.weightFrom - b.weightTo) / 2)
if sizeEqual && inter == 0 {
return matchConflict, coverage, iou, mid, "尺码码位一致,但体重区间完全不重叠。"
}
}
contained := a.hasWeight && b.hasWeight && a.weightFrom >= b.weightFrom && a.weightTo <= b.weightTo
widthOK := false
if contained {
width := b.weightTo - b.weightFrom
target := a.weightTo - a.weightFrom
widthOK = width <= math.Max(target*maxCandidateWidthRatio, minCandidateWidthAllowanceJin) && mid <= maxMidpointDistanceJin
}
if sizeEqual && contained && widthOK && colorHit {
return matchA, coverage, iou, mid, fmt.Sprintf("尺码码位一致(%s);目标体重区间被候选完整覆盖;主色一致。", a.size)
}
if sizeEqual && coverage > 0 && (!colorKnown || colorHit) {
return matchB, coverage, iou, mid, "尺码码位一致,体重区间部分重叠;请人工核对。"
}
return matchC, coverage, iou, mid, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。"
}
+74
View File
@@ -0,0 +1,74 @@
package service
import "testing"
func testChoices(values ...map[string]string) ([]PddOptionChoice, []string, []string) {
choices := make([]PddOptionChoice, 0, len(values))
for _, v := range values {
k, _ := OptionKey(v)
choices = append(choices, PddOptionChoice{Key: k, Label: v["color"] + " " + v["size"], Options: v})
}
return choices, []string{"color", "size"}, []string{"颜色分类", "尺码"}
}
func TestSpecMatch_带标签样本(t *testing.T) {
cases := []struct {
name, raw string
options []map[string]string
pre bool
level string
}{
{"正确第一名", "灰色-小個子,L建議53-57公斤", []map[string]string{{"color": "灰色中长款", "size": "L(106-114斤)"}, {"color": "黑色", "size": "L(106-114斤)"}}, true, "A"},
{"繁简单位", "藍色,5XL建議100-120公斤", []map[string]string{{"color": "蓝色长款", "size": "5XL(200-240斤)"}}, true, "A"},
{"部分重叠", "灰色,L建議55-60公斤", []map[string]string{{"color": "灰色", "size": "L(115-130斤)"}}, false, "B"},
{"无码位无主色", "53-57公斤", []map[string]string{{"color": "", "size": "L(80-115斤)"}}, false, "C"},
{"体重冲突", "L建議52-60公斤", []map[string]string{{"color": "", "size": "L(200-240斤)"}}, false, "冲突"},
{"颜色冲突", "黑色常規款,L建議52-60公斤", []map[string]string{{"color": "白色中长款", "size": "L(104-120斤)"}}, false, "冲突"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, k, n := testChoices(tc.options...)
r := rankSpecChoices(tc.raw, c, k, n)
if len(r.Choices) == 0 || r.Choices[0].MatchLevel != tc.level || (r.PreselectOptionKey != "") != tc.pre {
t.Fatalf("result=%+v", r)
}
})
}
}
func TestSpecMatch_繁简表与颜色别名逐条覆盖(t *testing.T) {
for _, p := range traditionalPairs {
if simplifyExplicit(p.from) != p.to {
t.Fatalf("%s", p.from)
}
}
for _, raw := range []string{"粉紅", "浅粉", "米白", "藏藍"} {
if len(extractSpecFeatures(raw).colors) == 0 {
t.Fatalf("%s", raw)
}
}
if !extractSpecFeatures("粉紅色").colors["粉"] || !extractSpecFeatures("粉色系").colors["粉"] {
t.Fatal("粉色别名未归一")
}
if !extractSpecFeatures("藏青").colors["藏青"] {
t.Fatal("藏青被拆分")
}
}
func TestSpecMatch_尺码最长优先(t *testing.T) {
for raw, want := range map[string]string{"XXL": "2XL", "2XL": "2XL", "XXXXXL": "5XL", "女M码": "M", "均码": "均码", "F": "F"} {
if got := extractSpecFeatures(raw).size; got != want {
t.Fatalf("%s=>%s", raw, got)
}
}
}
func TestSpecMatch_多维歧义不预选(t *testing.T) {
c, k, n := testChoices(map[string]string{"color": "灰", "size": "L(106-114斤)", "style": "A"}, map[string]string{"color": "灰", "size": "L(106-114斤)", "style": "B"})
k = append(k, "style")
n = append(n, "款式")
r := rankSpecChoices("灰色,L建議53-57公斤", c, k, n)
if r.PreselectOptionKey != "" || r.Notice == "" {
t.Fatalf("%+v", r)
}
}
+36 -22
View File
@@ -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
}