package service import ( "bytes" "database/sql/driver" "encoding/json" "errors" "regexp" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code` const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code` const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'` func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) { t.Helper() db, mock, err := sqlmock.New() if err != nil { t.Fatalf("open sql mock: %v", err) } previous := database.DB database.DB = db return mock, func() { database.DB = previous db.Close() } } func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) { 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")) 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")) if withRule { mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). AddRow(5, 1, 12, "A01", 4, 201, "active")) } else { mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) } } // expectImportLog 校验成功导入的汇总记录。 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)) } // 记录写入错误会被服务吞掉,单纯不设置 SQL 期望无法证明没有尝试写入。 // 设一个最后的写入期望,并确认只有它未被消费,且前面的业务期望全部完成。 func expectNoImportLog(t *testing.T, mock sqlmock.Sqlmock) func() { t.Helper() unexpected := mock.ExpectExec(`INSERT INTO material_import_logs`).WillReturnResult(sqlmock.NewResult(9, 1)) return func() { t.Helper() err := mock.ExpectationsWereMet() if err == nil { t.Fatal("失败导入不应写入历史记录") } want := "there is a remaining expectation which was not matched: " + unexpected.String() if err.Error() != want { t.Fatal(err) } } } func TestMaterialImportServiceDoesNotRecordMasterDataFailure(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() queryErr := errors.New("主数据查询失败") mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).WillReturnError(queryErr) defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"螺栓", "", "个", "机械结构类", "标准件"}, }) result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) if !errors.Is(err, queryErr) || result != nil { t.Fatalf("result = %+v, err = %v", result, err) } } func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, true) defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"", "", "", "机械结构类", "标准件"}, // 名称缺失 {"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在 }) result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) if err != nil { t.Fatalf("import: %v", err) } if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 { t.Fatalf("result = %+v", result) } if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") { t.Fatalf("first error = %+v", result.Errors[0]) } if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") { t.Fatalf("second error = %+v", result.Errors[1]) } } func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, false) defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"螺栓", "", "个", "机械结构类", "标准件"}, }) result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) if err != nil { t.Fatalf("import: %v", err) } if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { t.Fatalf("result = %+v", result) } } func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() defer expectNoImportLog(t, mock)() result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx")) if err == nil { t.Fatal("期望返回模板错误") } if result != nil { t.Fatalf("result = %+v, want nil", result) } } func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, true) // 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID // ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。 expectImportPersist(mock, "A010201") expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "") data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, }) 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 TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, true) mock.ExpectBegin() mock.ExpectQuery(`FOR UPDATE`).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`).WillReturnRows(sqlmock.NewRows([]string{"material_code"})) mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0)) // 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚 mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) mock.ExpectRollback() defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, }) result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) if err == nil { t.Fatal("期望入库失败") } if result != nil { t.Fatalf("result = %+v, want nil", result) } } // expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 // → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 type importSnapshot struct { code string } func (expected importSnapshot) Match(value driver.Value) bool { text, ok := value.(string) if !ok { return false } var snapshot struct { ID int64 `json:"id"` OrganizationID int64 `json:"organization_id"` MaterialCode string `json:"material_code"` Name string `json:"name"` Spec string `json:"spec"` CategoryID int64 `json:"category_id"` BaseUnitID int64 `json:"base_unit_id"` Unit string `json:"unit"` Status string `json:"status"` Version int `json:"version"` } return json.Unmarshal([]byte(text), &snapshot) == nil && snapshot.ID == 31 && snapshot.OrganizationID == 1 && snapshot.MaterialCode == expected.code && snapshot.Name == "内六角螺栓" && snapshot.Spec == "M8x30" && snapshot.CategoryID == 12 && snapshot.BaseUnitID == 1 && snapshot.Unit == "个" && snapshot.Status == "active" && snapshot.Version == 1 } 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)) description := "批量导入创建:内六角螺栓(" + code + ")\n物料编码:" + code + "\n名称:内六角螺栓\n规格:M8x30\n分类:机械结构类 / 标准件\n单位:个\n状态:启用" mock.ExpectExec(`INSERT INTO material_logs`). WithArgs("CREATE", int64(31), "", importSnapshot{code: code}, "tester", description). 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")) // 大类查不到,小类不再定位,也就不会去查编码规则 defer expectNoImportLog(t, mock)() 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) } } // 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 // 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) } }) } } // 物料表的 unit 只存一种写法:单位编码。模板里填名称(界面显示的是「千克」这类 // 显示名)时也得落成 kg,否则同一单位会按录入路径留下两种值——库存页直接显示该 // 字段(Stock.vue 的 unit 列),会同时出现「千克」和「kg」。 func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(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")) // 计量类单位:编码 kg、名称千克(计数类单位 code 与 name 相同,测不出差异) mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). AddRow(7, 1, "kg", "千克", 3, "active")) mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). AddRow(5, 1, 12, "A01", 4, 201, "active")) rows := []model.MaterialImportRow{ {Row: 2, Name: "内六角螺栓", Spec: "M8x30", Unit: "千克", MajorName: "机械结构类", MinorName: "标准件"}, {Row: 3, Name: "垫圈", Spec: "M8", Unit: "kg", MajorName: "机械结构类", MinorName: "标准件"}, } items, importErrors, err := NewMaterialImportService().validateRows(1, rows) if err != nil { t.Fatalf("validateRows: %v", err) } if len(importErrors) != 0 || len(items) != 2 { t.Fatalf("items = %d, importErrors = %+v", len(items), importErrors) } for i, item := range items { if item.Unit != "kg" { t.Fatalf("items[%d].Unit = %q, want 单位编码 %q", i, item.Unit, "kg") } } if items[0].BaseUnitID == nil || *items[0].BaseUnitID != 7 { t.Fatalf("baseUnitID = %v, want 7", items[0].BaseUnitID) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }