From f634e7cf071a7332436c6b15420e9d5c4ded5333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Sat, 10 Oct 2026 17:32:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=89=A9=E6=96=99=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=8C=B9=E9=85=8D=E5=85=BC=E5=AE=B9=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=B8=8A=E7=9A=84=E3=80=8C=E7=BC=96=E7=A0=81=20?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E3=80=8D=E5=86=99=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板, 却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按 分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。 - 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖, 结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法 - 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在, 把排查方向带偏了 - 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」) 验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码); 另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。 go build ./...、go vet ./internal/...、go test ./... 全部通过。 --- docs/api.md | 6 +- internal/service/material_import.go | 32 +++++- internal/service/material_import_excel.go | 4 +- internal/service/material_import_test.go | 120 +++++++++++++++++++--- 4 files changed, 136 insertions(+), 26 deletions(-) diff --git a/docs/api.md b/docs/api.md index d1985aa8..d47501e5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -410,7 +410,7 @@ - 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」; - 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。 -分类**按名称**填写,后端按「大类名 + 小类名」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 +分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 ### POST /material-import @@ -425,8 +425,8 @@ | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 规格 | 选填,≤ 200 字 | | 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | -| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | -| 小类 | 必填,必须是该大类下已启用的小类名称 | +| 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 | +| 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 | | 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) | 物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。同一行可能产生多条原因,用中文分号连接。 diff --git a/internal/service/material_import.go b/internal/service/material_import.go index 8738ec2c..aa20f24f 100644 --- a/internal/service/material_import.go +++ b/internal/service/material_import.go @@ -89,17 +89,28 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. } majors := make(map[string]*model.MaterialCategory) - minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名 + minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类写法 for i := range categories { category := &categories[i] if category.Status != "active" { continue } + // 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段 + // 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排 + // 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。 + aliases := []string{category.Name, category.Code} + if category.Code != "" { + aliases = append(aliases, category.Code+" "+category.Name) + } if category.ParentID == nil { - majors[category.Name] = category + for _, alias := range aliases { + registerCategoryAlias(majors, alias, category) + } continue } - minors[minorCategoryKey(*category.ParentID, category.Name)] = category + for _, alias := range aliases { + registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, alias), category) + } } unitByName := make(map[string]*model.Unit) unitByCode := make(map[string]*model.Unit) @@ -140,7 +151,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. if row.MajorName == "" { reasons = append(reasons, "大类不能为空") } else if major == nil { - reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName)) + reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName)) } var minor *model.MaterialCategory @@ -151,7 +162,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. } else { minor = minors[minorCategoryKey(major.ID, row.MinorName)] 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)) } } @@ -282,3 +293,14 @@ func summarizeImportErrors(importErrors []model.MaterialImportError) string { func minorCategoryKey(majorID int64, minorName string) string { return fmt.Sprintf("%d\x00%s", majorID, minorName) } + +// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用 +// 时不覆盖,保证结果不随分类的遍历顺序变化。 +func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) { + if key == "" { + return + } + if _, exists := index[key]; !exists { + index[key] = category + } +} diff --git a/internal/service/material_import_excel.go b/internal/service/material_import_excel.go index 262d5507..53c8fe7d 100644 --- a/internal/service/material_import_excel.go +++ b/internal/service/material_import_excel.go @@ -58,8 +58,8 @@ func BuildMaterialImportTemplate() ([]byte, error) { {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"规格", "选填", "M8x30", "最长 200 字"}, {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, - {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"}, - {"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"}, + {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, + {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"}, {"", "", "", ""}, {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, } diff --git a/internal/service/material_import_test.go b/internal/service/material_import_test.go index 634af2ea..85b2308b 100644 --- a/internal/service/material_import_test.go +++ b/internal/service/material_import_test.go @@ -132,22 +132,7 @@ func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { expectImportMasterData(mock, true) // 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID // ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。 - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)). - WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). - AddRow(5, 1, 12, "A01", 4, 201)) - mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`). - WithArgs(int64(1), "A01%"). - WillReturnRows(sqlmock.NewRows([]string{"material_code"})) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). - WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1)) - mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). - WithArgs(int64(1), "A010201"). - WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, "A010201")) - mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectCommit() + expectImportPersist(mock, "A010201") expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "") data := buildTestWorkbook(t, [][]string{ @@ -195,3 +180,106 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { t.Fatal(err) } } + +// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 +// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 +func expectImportPersist(mock sqlmock.Sqlmock, code string) { + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)). + WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). + AddRow(5, 1, 12, "A01", 4, 201)) + mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`). + WithArgs(int64(1), "A01%"). + WillReturnRows(sqlmock.NewRows([]string{"material_code"})) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). + WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1)) + mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). + WithArgs(int64(1), code). + WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code)) + mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() +} + +// 界面上分类一律显示成「编码 名称」(Materials.vue 的 categoryLabel),用户整段照抄 +// 进模板是常事;导入校验得认这种写法,否则分类明明存在也会报「不存在或已停用」。 +func TestMaterialImportServiceAcceptsCategoryDisplayLabels(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", "个", "A 机械结构类", "A01 标准件"}, + }) + 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) + } +} + +// 只填编码也认:模板说明写的是名称,但用户对着物料分类页抄编码同样应该通过。 +func TestMaterialImportServiceAcceptsCategoryCodes(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", "个", "A", "A01"}, + }) + 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) + } +} + +// 放宽的只是写法,不是启用状态:停用分类用「编码 名称」引用同样要挡下来。 +func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}). + AddRow(3, 1, "A", "机械结构类", "", nil, "active"). + AddRow(12, 1, "A01", "标准件", "", 3, "active"). + AddRow(20, 1, "B", "旧大类", "", nil, "inactive")) + mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). + AddRow(1, 1, "个", "个", 0, "active")) + // 大类查不到,小类不再定位,也就不会去查编码规则 + expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + + data := buildTestWorkbook(t, [][]string{ + {"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err != nil { + t.Fatalf("import: %v", err) + } + // 断言必须点到「大类本身不存在」:小类的报错里也带着「B 旧大类」这几个字, + // 只判 Contains("大类") 会被这层巧合放过。 + if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") { + t.Fatalf("result = %+v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}