Files
cmautobuy/admin/service/specmatch.go
T

265 lines
8.0 KiB
Go

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, "只有部分匹配信号,请人工核对颜色、尺码和体重范围。"
}