feat: 增加商品颜色匹配页面 (#294)

This commit is contained in:
chengma
2026-08-24 00:04:19 +08:00
parent 3cee6cbfaa
commit 8162ae5aca
12 changed files with 697 additions and 1 deletions
+147
View File
@@ -0,0 +1,147 @@
package service
import (
"fmt"
"sort"
"strings"
)
// ProductColorMappingOption 是原生 select 中的一个可购买 PDD 颜色。
type ProductColorMappingOption struct {
Value, Label string
Selected bool
}
// ProductColorMappingOptionGroup 用 optgroup 表达当前、未使用和已匹配三组候选。
type ProductColorMappingOptionGroup struct {
Label string
Options []ProductColorMappingOption
}
// ProductColorMappingPageRow 是正式页面的一行。
type ProductColorMappingPageRow struct {
ColorKey, ColorRaw, SourceText string
OriginalValue, SelectedValue string
Status, StatusText string
MappingText string
SelectedInvalid bool
Groups []ProductColorMappingOptionGroup
}
// ProductColorMappingPageView 只负责把领域上下文翻译成模板可直接显示的数据。
type ProductColorMappingPageView struct {
ShopeeGoodsID, ShopeeTitle, PddGoodsID, PddTitle string
PddDimensionKey, ContextVersion string
UnavailableReason string
Rows []ProductColorMappingPageRow
MappedCount, ValidCount, InvalidCount int
}
// BuildProductColorMappingPageView 生成分组候选。overrides 只用于 POST 失败后回显,
// 不会改变服务端候选或有效性判断。
func BuildProductColorMappingPageView(context *ProductColorMappingContext, overrides map[string]string) *ProductColorMappingPageView {
view := &ProductColorMappingPageView{
ShopeeGoodsID: context.ShopeeGoodsID, ShopeeTitle: context.ShopeeTitle,
PddGoodsID: context.PddGoodsID, PddTitle: context.PddTitle,
PddDimensionKey: context.PddDimensionKey, ContextVersion: context.ContextVersion,
UnavailableReason: context.UnavailableReason,
}
occupiedBy := map[string][]string{}
for _, row := range context.Rows {
if row.Mapping != nil && row.MappingValid {
occupiedBy[row.Mapping.PddColorValue] = append(occupiedBy[row.Mapping.PddColorValue], row.Color.Raw)
}
}
for value := range occupiedBy {
sort.Strings(occupiedBy[value])
}
candidateByValue := make(map[string]PddColorCandidate, len(context.Candidates))
for _, candidate := range context.Candidates {
candidateByValue[candidate.Value] = candidate
}
for _, sourceRow := range context.Rows {
row := ProductColorMappingPageRow{
ColorKey: sourceRow.Color.Key, ColorRaw: sourceRow.Color.Raw,
SourceText: sourceRow.Color.SourceText(), Status: "pending", StatusText: "待匹配",
}
if sourceRow.Mapping != nil {
row.OriginalValue = sourceRow.Mapping.PddColorValue
row.SelectedValue = sourceRow.Mapping.PddColorValue
row.MappingText = "人工维护"
if strings.TrimSpace(sourceRow.Mapping.MappedBy) != "" {
row.MappingText += " · " + sourceRow.Mapping.MappedBy
}
if sourceRow.MappingValid {
row.Status, row.StatusText = "mapped", "已匹配"
view.ValidCount++
} else {
row.Status, row.StatusText, row.SelectedInvalid = "invalid", "映射已失效", true
view.InvalidCount++
}
view.MappedCount++
}
if selected, ok := overrides[sourceRow.Color.Key]; ok {
row.SelectedValue = strings.TrimSpace(selected)
_, targetExists := candidateByValue[row.SelectedValue]
row.SelectedInvalid = row.SelectedValue != "" && !targetExists
}
row.Groups = buildColorMappingOptionGroups(context.Candidates, occupiedBy, row.SelectedValue)
view.Rows = append(view.Rows, row)
}
return view
}
func buildColorMappingOptionGroups(candidates []PddColorCandidate, occupiedBy map[string][]string, selected string) []ProductColorMappingOptionGroup {
var current, unused, used []ProductColorMappingOption
for _, candidate := range candidates {
label := pddColorCandidateLabel(candidate)
option := ProductColorMappingOption{Value: candidate.Value, Label: label, Selected: candidate.Value == selected}
if candidate.Value == selected {
current = append(current, option)
continue
}
if colors := occupiedBy[candidate.Value]; len(colors) > 0 {
option.Label += "(已匹配:" + strings.Join(colors, " / ") + ")"
used = append(used, option)
} else {
unused = append(unused, option)
}
}
groups := make([]ProductColorMappingOptionGroup, 0, 3)
if len(current) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "当前选择", Options: current})
}
if len(unused) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "未使用的颜色", Options: unused})
}
if len(used) > 0 {
groups = append(groups, ProductColorMappingOptionGroup{Label: "已经匹配(仍可选择)", Options: used})
}
return groups
}
func pddColorCandidateLabel(candidate PddColorCandidate) string {
price := "价格未采到"
if candidate.HasPrice {
if candidate.MinPriceCent == candidate.MaxPriceCent {
price = fmt.Sprintf("¥%.2f", float64(candidate.MinPriceCent)/100)
} else {
price = fmt.Sprintf("¥%.2f–%.2f", float64(candidate.MinPriceCent)/100, float64(candidate.MaxPriceCent)/100)
}
}
return fmt.Sprintf("%s · %s · %d 个可购买规格", candidate.Value, price, candidate.SKUCount)
}
// ProductColorMappingStatusLine 返回页面底部的短状态。
func ProductColorMappingStatusLine(view *ProductColorMappingPageView) string {
if view.UnavailableReason != "" {
return view.UnavailableReason
}
return fmt.Sprintf("共 %d 个蝦皮颜色 · 已匹配 %d · 待匹配 %d · 已失效 %d",
len(view.Rows), view.ValidCount, len(view.Rows)-view.MappedCount, view.InvalidCount)
}
// ProductColorMappingHasChanges 供测试明确局部保存的比较规则。
func ProductColorMappingHasChanges(row ProductColorMappingPageRow) bool {
return row.OriginalValue != row.SelectedValue
}
+24
View File
@@ -42,6 +42,30 @@ func TestAggregatePddColorCandidates_拒绝多个显式颜色维度(t *testing.T
}
}
func TestBuildProductColorMappingPageView_未使用优先且已使用仍可多对一(t *testing.T) {
context := &ProductColorMappingContext{
ShopeeGoodsID: "S-1", PddGoodsID: "P-1", PddDimensionKey: "color", ContextVersion: "ctx",
Candidates: []PddColorCandidate{
{DimensionKey: "color", Value: "曜石黑", HasPrice: true, MinPriceCent: 1180, MaxPriceCent: 1280, SKUCount: 2},
{DimensionKey: "color", Value: "奶油白", SKUCount: 1},
},
Rows: []ProductColorMappingRow{
{Color: ProductColorSource{Key: "黑色", Raw: "黑色", FormalCount: 1}, MappingValid: true,
Mapping: &model.ProductColorMapping{PddColorValue: "曜石黑", PddDimensionKey: "color", MappedBy: "U-1"}},
{Color: ProductColorSource{Key: "深黑", Raw: "深黑", SybCount: 2}},
},
}
view := BuildProductColorMappingPageView(context, map[string]string{"深黑": "曜石黑"})
if view.ValidCount != 1 || len(view.Rows) != 2 || !ProductColorMappingHasChanges(view.Rows[1]) {
t.Fatalf("页面汇总或失败回显不正确:%+v", view)
}
groups := view.Rows[1].Groups
if len(groups) != 2 || groups[0].Label != "当前选择" || groups[0].Options[0].Value != "曜石黑" ||
groups[1].Label != "未使用的颜色" || groups[1].Options[0].Value != "奶油白" {
t.Fatalf("候选分组错误:%+v", groups)
}
}
func TestSaveProductColorMappings_保存审计并用上下文版本防过期(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "S-COLOR", "颜色映射商品")