material_manage_system/internal/service/material_import_test.go
文睿 张 f634e7cf07 fix: 物料导入分类匹配兼容界面上的「编码 名称」写法
界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板,
却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按
分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。

- 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖,
  结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法
- 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在,
  把排查方向带偏了
- 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」)

验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码);
另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。
go build ./...、go vet ./internal/...、go test ./... 全部通过。
2026-10-10 17:32:15 +08:00

286 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"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"}))
}
}
// 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))
}
func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, true)
expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg())
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])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportMasterData(mock, false)
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
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)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
mock, cleanup := newImportServiceMock(t)
defer cleanup()
expectImportLog(mock, "错的.xlsx", "failed", 0, 0, 0, sqlmock.AnyArg())
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)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
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()
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
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)
}
if err := mock.ExpectationsWereMet(); err != nil {
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)
}
}