diff --git a/docs/superpowers/plans/2026-10-10-material-bulk-import.md b/docs/superpowers/plans/2026-10-10-material-bulk-import.md index c54e7d6f..97cdc157 100644 --- a/docs/superpowers/plans/2026-10-10-material-bulk-import.md +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -47,7 +47,7 @@ 「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。 -分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 +分类**按名称填写**,同时兼容界面上的「编码 名称」写法与纯编码(前端分类下拉显示的就是「编码 名称」,用户整段照抄是常事);后端按「大类 ID + 小类写法」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。匹配前先把各种空白(半角空格、全角空格 U+3000、NBSP U+00A0、制表符)归一化成单个半角空格,否则会出现「从浏览器复制的能导入、自己手写的报不存在」。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 ### 3. 接口 @@ -92,8 +92,8 @@ | 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | | 规格 | 选填,≤ 200 字 | | 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | -| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | -| 小类 | 必填,必须是该大类下已启用的小类名称 | +| 大类 | 必填,必须是当前公司已启用的顶层分类:名称、编码或「编码 名称」写法,匹配前归一化空白 | +| 小类 | 必填,必须是该大类下已启用的小类:名称、编码或「编码 名称」写法(按大类 ID + 小类写法组合定位) | | 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 | 同一行可产生多条原因,用中文分号连接。 @@ -554,8 +554,8 @@ func BuildMaterialImportTemplate() ([]byte, error) { {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"规格", "选填", "M8x30", "最长 200 字"}, {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, - {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"}, - {"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"}, + {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, + {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"}, {"", "", "", ""}, {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, } @@ -695,7 +695,7 @@ func materialImportCell(row []string, index int) string { - [ ] **Step 5: 跑测试确认通过** Run: `go test ./internal/service/ -run 'MaterialImport(File|Template)' -v` -Expected: PASS(全部 4 个函数) +Expected: PASS(全部 7 个函数) - [ ] **Step 6: Commit** @@ -744,6 +744,8 @@ import ( const importLockRuleQuery = `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` +const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) { t.Helper() db, mock, err := sqlmock.New() @@ -758,6 +760,8 @@ func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) { } } +func materialCategoryPtr(id int64) *int64 { return &id } + func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) { mock, cleanup := newImportMock(t) defer cleanup() @@ -904,13 +908,7 @@ func TestMaterialLogRepoCreateBatchTx(t *testing.T) { } ``` -**注意:** 仓库里 `insertMaterialStmt` 定义在 `internal/service/material_test.go`(service 包),repository 包内没有同名常量。批量插入是**多值**语句,所以要在 `internal/repository/material_import_test.go` 里按参与行数拼出期望文本。测试里用到的是两行一批: - -```go -const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` -``` - -行数更多的场景(如 501 行那一条)用 `mock.ExpectExec("INSERT INTO materials")` 松匹配即可,不必拼 501 组占位符。 +**注意:** 仓库里 `insertMaterialStmt` 定义在 `internal/service/material_test.go`(service 包),repository 包内没有同名常量,所以上面 Step 1 的代码块里自带了 `insertMaterialBatchStmt`(两行一批的多值语句)。行数更多的场景(如 501 行那一条)改用 `mock.ExpectExec("INSERT INTO materials")` 松匹配,不必拼 501 组占位符。 - [ ] **Step 2: 跑测试确认失败** @@ -1307,12 +1305,11 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) { } } -// fileName 由调用方指定:多数用例用「物料.xlsx」,模板非法那条用的是另一个名字。 -// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() 表示 -// 只校验"写了一条记录",不锁死文案。 -// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), -// sqlmock 按类型比对,直接传 Go int 匹配不上。 +// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() +// 表示只校验"写了一条记录",不锁死文案。 func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) { + // 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), + // sqlmock 按类型比对,传 Go int 匹配不上。 mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). WithArgs(int64(1), "tester", fileName, int64(total), int64(success), int64(failed), status, reason). WillReturnResult(sqlmock.NewResult(9, 1)) @@ -1326,7 +1323,7 @@ func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg()) data := buildTestWorkbook(t, [][]string{ - {"", "", "", "机械结构类", "标准件"}, // 名称缺失 + {"", "", "", "机械结构类", "标准件"}, // 名称缺失 {"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在 }) result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) @@ -1394,22 +1391,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{ @@ -1457,6 +1439,151 @@ 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) + } +} + +// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 +// U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成 +// 「从浏览器复制的能导入、自己手写的报不存在」。 +// +// 仓库源码里这两种空格写成字面字符;它们在源码里近乎隐形、容易被编辑器或编码转换 +// 悄悄改掉(和 BOM 同理),所以本文档改用 Go 的 \u 转义写——两种写法完全等价。 +func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) { + cases := []struct { + name string + major string + minor string + }{ + {"全角空格 U+3000", "A\u3000机械结构类", "A01\u3000标准件"}, + {"不换行空格 U+00A0", "A\u00A0机械结构类", "A01\u00A0标准件"}, + {"连续半角空格", "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) + } + }) + } +} ``` - [ ] **Step 2: 跑测试确认失败** @@ -1560,17 +1687,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, normalizeWhitespace(alias), category) + } continue } - minors[minorCategoryKey(*category.ParentID, category.Name)] = category + for _, alias := range aliases { + registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, normalizeWhitespace(alias)), category) + } } unitByName := make(map[string]*model.Unit) unitByCode := make(map[string]*model.Unit) @@ -1607,22 +1745,24 @@ 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)) + 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)) + reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用,请核对分类名称或编码", row.MinorName, row.MajorName)) } } @@ -1753,12 +1893,33 @@ func summarizeImportErrors(importErrors []model.MaterialImportError) string { 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) { + if key == "" { + return + } + if _, exists := index[key]; !exists { + index[key] = category + } +} ``` - [ ] **Step 4: 跑测试确认通过** Run: `go test ./internal/service/ -run TestMaterialImportService -v` -Expected: PASS(5 个测试) +Expected: PASS(9 个用例;其中空白变体那条是表驱动,跑 3 组子用例) - [ ] **Step 5: 跑全仓测试**