diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index cc9fb2d..f496453 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -5,11 +5,11 @@ import ( "database/sql" "encoding/json" "fmt" - "regexp" "sort" "strconv" "strings" "time" + "unicode" "cmautobuy/admin/model" "cmautobuy/admin/repository" @@ -419,19 +419,103 @@ func innerCodeStallMatches(stall string, item syb.DetailItem) bool { if strings.Contains(blob, stall) { return true } - name, article, hasArticle := strings.Cut(stall, "#") + name, article, hasArticle := splitInnerCodeStall(stall) if !hasArticle { return false } - name, article = strings.TrimSpace(name), strings.TrimSpace(article) - if article != "" { - if strings.Contains(blob, "#"+article) || strings.HasPrefix(item.ProductSpec, article+" ") || strings.HasPrefix(item.ProductSpec, article+",") { + if article == "" { + return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + } + + // 纯数字货号容易在不同档口重复。只有候选也包含同一档口名称时, + // 才允许把 067 与 67 视为同一个货号;不能只凭短数字自动选商品。 + if isInnerCodeNumericArticle(article) { + nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + if !nameMatches { + return false + } + return innerCodeTextHasNumericArticle(sku, article) || + innerCodeTextHasNumericArticle(variation, article) || + innerCodeTextHasNumericArticle(item.ProductSpec, article) + } + + return innerCodeTextHasExactArticle(sku, article) || + innerCodeTextHasExactArticle(variation, article) || + innerCodeTextHasExactArticle(item.ProductSpec, article) +} + +// splitInnerCodeStall 从“档口名称#货号”取最后一个 #,避免档口名称本身含 # 时截错。 +func splitInnerCodeStall(stall string) (name, article string, ok bool) { + stall = strings.TrimSpace(stall) + separator := strings.LastIndex(stall, "#") + if separator < 0 { + return stall, "", false + } + return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true +} + +func isInnerCodeNumericArticle(article string) bool { + if article == "" { + return false + } + for _, char := range article { + if !unicode.IsDigit(char) { + return false + } + } + return true +} + +func innerCodeTextHasNumericArticle(text, article string) bool { + target := normalizeInnerCodeNumericArticle(article) + for _, token := range innerCodeArticleTokens(text) { + if isInnerCodeNumericArticle(token) && normalizeInnerCodeNumericArticle(token) == target { return true } - pattern := regexp.MustCompile(`(?:^|[#\-_/\s])` + regexp.QuoteMeta(article) + `(?:$|[#\-_/,\s])`) - return pattern.MatchString(sku) || pattern.MatchString(variation) } - return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name)) + return false +} + +func normalizeInnerCodeNumericArticle(article string) string { + normalized := strings.TrimLeft(article, "0") + if normalized == "" { + return "0" + } + return normalized +} + +func innerCodeTextHasExactArticle(text, article string) bool { + for _, token := range innerCodeArticleTokens(text) { + if token == article { + return true + } + } + return false +} + +// innerCodeArticleTokens 只把连续字母或数字视为货号候选。 +// `【】`、`#`、横线、空格等标点自然成为边界,因此能识别 `067【档口】`, +// 又不会把 `PDD256437` 中间的数字误认为独立货号。 +func innerCodeArticleTokens(text string) []string { + tokens := make([]string, 0) + start := -1 + runes := []rune(text) + for index, char := range runes { + if unicode.IsLetter(char) || unicode.IsDigit(char) { + if start < 0 { + start = index + } + continue + } + if start >= 0 { + tokens = append(tokens, string(runes[start:index])) + start = -1 + } + } + if start >= 0 { + tokens = append(tokens, string(runes[start:])) + } + return tokens } func innerCodeRawText(value any) string { diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index c6f512a..4c23402 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -223,6 +223,75 @@ func TestMatchInnerCodeItemsWithEvidence_重复和证据冲突均阻断(t *testi } } +func TestInnerCodeStallMatches_中文括号货号与数字前导零(t *testing.T) { + tests := []struct { + name string + stall string + item syb.DetailItem + want bool + }{ + { + name: "067在中文括号前为独立货号", + stall: "将客大码网批#067", + item: innerCodeTestItem(1, "黑色(有口袋),2XL【建議67.5-82.5公斤】", map[string]any{ + "sku": "067【将客大码网批】-黑色(有口袋)-2XL", + }), + want: true, + }, + { + name: "同档口67与067等价", + stall: "将客大码网批#067", + item: innerCodeTestItem(2, "黑色,2XL", map[string]any{ + "variationSku": "汽配-将客大码网批#67#(&19)", + }), + want: true, + }, + { + name: "同货号不同档口不匹配", + stall: "将客大码网批#067", + item: innerCodeTestItem(3, "黑色,2XL", map[string]any{ + "sku": "067【其他档口】-黑色-2XL", "variationSku": "汽配-其他档口#67", + }), + want: false, + }, + { + name: "非数字货号不做类似转换", + stall: "A档#A01", + item: innerCodeTestItem(4, "黑色,M", map[string]any{"sku": "A档#A1"}), + want: false, + }, + { + name: "非数字货号精确匹配", + stall: "A档#A01", + item: innerCodeTestItem(5, "黑色,M", map[string]any{"sku": "A档#A01"}), + want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := innerCodeStallMatches(test.stall, test.item); got != test.want { + t.Fatalf("innerCodeStallMatches(%q)=%t,期望 %t", test.stall, got, test.want) + } + }) + } +} + +func TestPlanInnerCodeRows_将客档口067唯一匹配生产样例(t *testing.T) { + record := model.InnerCodeRecord{ID: 255, OrderNumber: "260814A21AAB1G", + Stall: "将客大码网批#067", SpecRaw: "黑色(有口袋),2XL【建議67.5-82.5公斤】", + InnerCode: "DK-255", SourceDuplicateCount: 1} + item := innerCodeTestItem(147048059, record.SpecRaw, map[string]any{ + "sku": "067【将客大码网批】-黑色(有口袋)-2XL", + "variationSku": "汽配-将客大码网批#67#(&19)", + }) + plans := planInnerCodeRows([]model.InnerCodeRecord{record}, + map[string][]int64{record.OrderNumber: {76159471}}, + map[int64]syb.StockDetail{76159471: {ID: 76159471, Details: []syb.DetailItem{item}}}) + if len(plans) != 1 || plans[0].Status != model.InnerCodeReady || plans[0].DetailID != item.ID { + t.Fatalf("将客档口记录未生成可回写计划: %+v", plans) + } +} + func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) { records := []model.InnerCodeRecord{ {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},