diff --git a/internal/service/material_import.go b/internal/service/material_import.go index aa20f24f..69cc56be 100644 --- a/internal/service/material_import.go +++ b/internal/service/material_import.go @@ -104,12 +104,12 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. } if category.ParentID == nil { for _, alias := range aliases { - registerCategoryAlias(majors, alias, category) + registerCategoryAlias(majors, normalizeWhitespace(alias), category) } continue } for _, alias := range aliases { - registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, alias), category) + registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, normalizeWhitespace(alias)), category) } } unitByName := make(map[string]*model.Unit) @@ -147,20 +147,22 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. reasons = append(reasons, "规格超过 200 字") } - major := majors[row.MajorName] - if row.MajorName == "" { + majorName := normalizeWhitespace(row.MajorName) + major := majors[majorName] + if majorName == "" { reasons = append(reasons, "大类不能为空") } else if major == nil { reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName)) } + minorName := normalizeWhitespace(row.MinorName) var minor *model.MaterialCategory - if row.MinorName == "" { + if minorName == "" { reasons = append(reasons, "小类不能为空") } else if major == nil { // 大类已经报错,小类无法定位,不重复报 } else { - minor = minors[minorCategoryKey(major.ID, row.MinorName)] + minor = minors[minorCategoryKey(major.ID, minorName)] if minor == nil { reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用,请核对分类名称或编码", row.MinorName, row.MajorName)) } @@ -294,6 +296,16 @@ func minorCategoryKey(majorID int64, minorName string) string { return fmt.Sprintf("%d\x00%s", majorID, minorName) } +// normalizeWhitespace 把文本里的各种空白(半角空格、全角空格 U+3000、不换行空格 +// U+00A0、制表符等)统一压成单个半角空格,并去掉首尾。 +// +// 界面上的分类显示名是前端拼出来的「编码 + 半角空格 + 名称」,用户照抄进模板时, +// 那个空格在中文输入法全角模式下会变成 U+3000、从网页复制偶尔带 U+00A0——两串肉眼 +// 一模一样、码点不同,不归一化就成了「浏览器复制的能导入、自己手写的报不存在」。 +func normalizeWhitespace(text string) string { + return strings.Join(strings.Fields(text), " ") +} + // registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用 // 时不覆盖,保证结果不随分类的遍历顺序变化。 func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) { diff --git a/internal/service/material_import_test.go b/internal/service/material_import_test.go index 85b2308b..67fa8cd1 100644 --- a/internal/service/material_import_test.go +++ b/internal/service/material_import_test.go @@ -283,3 +283,42 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { t.Fatal(err) } } + +// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 +// U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成 +// 「从浏览器复制的能导入、自己手写的报不存在」。 +func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) { + cases := []struct { + name string + major string + minor string + }{ + {"全角空格 U+3000", "A 机械结构类", "A01 标准件"}, + {"不换行空格 U+00A0", "A 机械结构类", "A01 标准件"}, + {"连续半角空格", "A 机械结构类", "A01 标准件"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + expectImportMasterData(mock, true) + expectImportPersist(mock, "A010201") + expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "") + + data := buildTestWorkbook(t, [][]string{ + {"内六角螺栓", "M8x30", "个", tc.major, tc.minor}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err != nil { + t.Fatalf("import: %v", err) + } + if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 { + t.Fatalf("result = %+v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +}