fix: 物料导入分类名归一化空白,兼容全角空格与 NBSP

界面上的分类显示名由前端拼成「编码 + 半角空格 + 名称」(U+0020),用户照抄进模板
时,中文输入法全角模式敲出的空格是 U+3000、从网页复制偶尔带 U+00A0。几串肉眼
一模一样、码点不同,精确匹配落空,于是出现「浏览器复制的能导入、自己手写的报
不存在」——分类名一个字没差,差的是那个看不见的空格。

- 匹配前用 normalizeWhitespace 把各种空白(半角、全角、NBSP、制表符)压成单个
  半角空格并去首尾;索引键与查询值同口径归一化,两边一致
- 只填空白的单元格现在报「大类/小类不能为空」,而不是「不存在或已停用」——它本来
  就是空的

验证:新增表驱动用例覆盖 3 种变体(全角空格、不换行空格、连续半角空格),修复前
3 个全部失败、修复后全部通过;go build ./...、go vet ./internal/...、
go test ./... 全部通过。
This commit is contained in:
文睿 张 2026-10-10 17:44:29 +08:00
parent ec7742a39c
commit 3c75537f54
2 changed files with 57 additions and 6 deletions

View File

@ -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) {

View File

@ -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)
}
})
}
}