docs: 计划文档同步物料导入的分类别名匹配与测试片段
计划里的代码片段落后于实现:分类匹配后来支持了「编码 名称」写法、纯编码与空白 归一化(f634e7cf、3c75537f 两个修复提交只改了代码和 docs/api.md,没回写计划)。 - Task 5 的实现与测试片段同步为实际代码,补 expectImportPersist 与 4 个新用例 - Task 2 模板「填写说明」文案补上「或编码」 - Task 3 测试块补 materialCategoryPtr 定义,insertMaterialBatchStmt 收进代码块 - 修正测试数量断言(7 个函数、9 个用例) - 设计约定与校验规则表补上分类可按名称/编码/「编码 名称」匹配、匹配前归一化空白
This commit is contained in:
parent
3c75537f54
commit
2e526be3fb
|
|
@ -47,7 +47,7 @@
|
||||||
|
|
||||||
「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。
|
「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。
|
||||||
|
|
||||||
分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
分类**按名称填写**,同时兼容界面上的「编码 名称」写法与纯编码(前端分类下拉显示的就是「编码 名称」,用户整段照抄是常事);后端按「大类 ID + 小类写法」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。匹配前先把各种空白(半角空格、全角空格 U+3000、NBSP U+00A0、制表符)归一化成单个半角空格,否则会出现「从浏览器复制的能导入、自己手写的报不存在」。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
||||||
|
|
||||||
### 3. 接口
|
### 3. 接口
|
||||||
|
|
||||||
|
|
@ -92,8 +92,8 @@
|
||||||
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
|
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
|
||||||
| 规格 | 选填,≤ 200 字 |
|
| 规格 | 选填,≤ 200 字 |
|
||||||
| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
|
| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
|
||||||
| 大类 | 必填,必须是当前公司已启用的顶层分类名称 |
|
| 大类 | 必填,必须是当前公司已启用的顶层分类:名称、编码或「编码 名称」写法,匹配前归一化空白 |
|
||||||
| 小类 | 必填,必须是该大类下已启用的小类名称 |
|
| 小类 | 必填,必须是该大类下已启用的小类:名称、编码或「编码 名称」写法(按大类 ID + 小类写法组合定位) |
|
||||||
| 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 |
|
| 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 |
|
||||||
|
|
||||||
同一行可产生多条原因,用中文分号连接。
|
同一行可产生多条原因,用中文分号连接。
|
||||||
|
|
@ -554,8 +554,8 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
|
{"物料名称", "必填", "内六角螺栓", "最长 200 字"},
|
||||||
{"规格", "选填", "M8x30", "最长 200 字"},
|
{"规格", "选填", "M8x30", "最长 200 字"},
|
||||||
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
|
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"},
|
||||||
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"},
|
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"},
|
||||||
{"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"},
|
{"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"},
|
||||||
{"", "", "", ""},
|
{"", "", "", ""},
|
||||||
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
|
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
|
||||||
}
|
}
|
||||||
|
|
@ -695,7 +695,7 @@ func materialImportCell(row []string, index int) string {
|
||||||
- [ ] **Step 5: 跑测试确认通过**
|
- [ ] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `go test ./internal/service/ -run 'MaterialImport(File|Template)' -v`
|
Run: `go test ./internal/service/ -run 'MaterialImport(File|Template)' -v`
|
||||||
Expected: PASS(全部 4 个函数)
|
Expected: PASS(全部 7 个函数)
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **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 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()) {
|
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, mock, err := sqlmock.New()
|
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) {
|
func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
|
||||||
mock, cleanup := newImportMock(t)
|
mock, cleanup := newImportMock(t)
|
||||||
defer cleanup()
|
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` 里按参与行数拼出期望文本。测试里用到的是两行一批:
|
**注意:** 仓库里 `insertMaterialStmt` 定义在 `internal/service/material_test.go`(service 包),repository 包内没有同名常量,所以上面 Step 1 的代码块里自带了 `insertMaterialBatchStmt`(两行一批的多值语句)。行数更多的场景(如 501 行那一条)改用 `mock.ExpectExec("INSERT INTO materials")` 松匹配,不必拼 501 组占位符。
|
||||||
|
|
||||||
```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 组占位符。
|
|
||||||
|
|
||||||
- [ ] **Step 2: 跑测试确认失败**
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
||||||
|
|
@ -1307,12 +1305,11 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileName 由调用方指定:多数用例用「物料.xlsx」,模板非法那条用的是另一个名字。
|
// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg()
|
||||||
// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() 表示
|
// 表示只校验"写了一条记录",不锁死文案。
|
||||||
// 只校验"写了一条记录",不锁死文案。
|
|
||||||
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
|
|
||||||
// sqlmock 按类型比对,直接传 Go int 匹配不上。
|
|
||||||
func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
|
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 (?, ?, ?, ?, ?, ?, ?, ?)`)).
|
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).
|
WithArgs(int64(1), "tester", fileName, int64(total), int64(success), int64(failed), status, reason).
|
||||||
WillReturnResult(sqlmock.NewResult(9, 1))
|
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||||
|
|
@ -1394,22 +1391,7 @@ func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
|
||||||
expectImportMasterData(mock, true)
|
expectImportMasterData(mock, true)
|
||||||
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
|
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
|
||||||
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
|
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
|
||||||
mock.ExpectBegin()
|
expectImportPersist(mock, "A010201")
|
||||||
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()
|
|
||||||
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
||||||
|
|
||||||
data := buildTestWorkbook(t, [][]string{
|
data := buildTestWorkbook(t, [][]string{
|
||||||
|
|
@ -1457,6 +1439,151 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
|
||||||
t.Fatal(err)
|
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: 跑测试确认失败**
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
@ -1560,17 +1687,28 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
}
|
}
|
||||||
|
|
||||||
majors := make(map[string]*model.MaterialCategory)
|
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 {
|
for i := range categories {
|
||||||
category := &categories[i]
|
category := &categories[i]
|
||||||
if category.Status != "active" {
|
if category.Status != "active" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段
|
||||||
|
// 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排
|
||||||
|
// 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。
|
||||||
|
aliases := []string{category.Name, category.Code}
|
||||||
|
if category.Code != "" {
|
||||||
|
aliases = append(aliases, category.Code+" "+category.Name)
|
||||||
|
}
|
||||||
if category.ParentID == nil {
|
if category.ParentID == nil {
|
||||||
majors[category.Name] = category
|
for _, alias := range aliases {
|
||||||
|
registerCategoryAlias(majors, normalizeWhitespace(alias), category)
|
||||||
|
}
|
||||||
continue
|
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)
|
unitByName := make(map[string]*model.Unit)
|
||||||
unitByCode := 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 字")
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1753,12 +1893,33 @@ func summarizeImportErrors(importErrors []model.MaterialImportError) string {
|
||||||
func minorCategoryKey(majorID int64, minorName string) string {
|
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 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
||||||
|
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
||||||
|
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: 跑测试确认通过**
|
- [ ] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
Run: `go test ./internal/service/ -run TestMaterialImportService -v`
|
Run: `go test ./internal/service/ -run TestMaterialImportService -v`
|
||||||
Expected: PASS(5 个测试)
|
Expected: PASS(9 个用例;其中空白变体那条是表驱动,跑 3 组子用例)
|
||||||
|
|
||||||
- [ ] **Step 5: 跑全仓测试**
|
- [ ] **Step 5: 跑全仓测试**
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user