fix: 物料导入分类名归一化空白,兼容全角空格与 NBSP
界面上的分类显示名由前端拼成「编码 + 半角空格 + 名称」(U+0020),用户照抄进模板 时,中文输入法全角模式敲出的空格是 U+3000、从网页复制偶尔带 U+00A0。几串肉眼 一模一样、码点不同,精确匹配落空,于是出现「浏览器复制的能导入、自己手写的报 不存在」——分类名一个字没差,差的是那个看不见的空格。 - 匹配前用 normalizeWhitespace 把各种空白(半角、全角、NBSP、制表符)压成单个 半角空格并去首尾;索引键与查询值同口径归一化,两边一致 - 只填空白的单元格现在报「大类/小类不能为空」,而不是「不存在或已停用」——它本来 就是空的 验证:新增表驱动用例覆盖 3 种变体(全角空格、不换行空格、连续半角空格),修复前 3 个全部失败、修复后全部通过;go build ./...、go vet ./internal/...、 go test ./... 全部通过。
This commit is contained in:
parent
ec7742a39c
commit
3c75537f54
|
|
@ -104,12 +104,12 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
}
|
}
|
||||||
if category.ParentID == nil {
|
if category.ParentID == nil {
|
||||||
for _, alias := range aliases {
|
for _, alias := range aliases {
|
||||||
registerCategoryAlias(majors, alias, category)
|
registerCategoryAlias(majors, normalizeWhitespace(alias), category)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, alias := range aliases {
|
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)
|
unitByName := make(map[string]*model.Unit)
|
||||||
|
|
@ -147,20 +147,22 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
reasons = append(reasons, "规格超过 200 字")
|
reasons = append(reasons, "规格超过 200 字")
|
||||||
}
|
}
|
||||||
|
|
||||||
major := majors[row.MajorName]
|
majorName := normalizeWhitespace(row.MajorName)
|
||||||
if row.MajorName == "" {
|
major := majors[majorName]
|
||||||
|
if majorName == "" {
|
||||||
reasons = append(reasons, "大类不能为空")
|
reasons = append(reasons, "大类不能为空")
|
||||||
} else if major == nil {
|
} else if major == nil {
|
||||||
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
|
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minorName := normalizeWhitespace(row.MinorName)
|
||||||
var minor *model.MaterialCategory
|
var minor *model.MaterialCategory
|
||||||
if row.MinorName == "" {
|
if minorName == "" {
|
||||||
reasons = append(reasons, "小类不能为空")
|
reasons = append(reasons, "小类不能为空")
|
||||||
} else if major == nil {
|
} else if major == nil {
|
||||||
// 大类已经报错,小类无法定位,不重复报
|
// 大类已经报错,小类无法定位,不重复报
|
||||||
} else {
|
} else {
|
||||||
minor = minors[minorCategoryKey(major.ID, row.MinorName)]
|
minor = minors[minorCategoryKey(major.ID, minorName)]
|
||||||
if minor == nil {
|
if minor == nil {
|
||||||
reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用,请核对分类名称或编码", row.MinorName, row.MajorName))
|
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)
|
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 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
||||||
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
||||||
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
|
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
|
||||||
|
|
|
||||||
|
|
@ -283,3 +283,42 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user