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