界面上的分类显示名由前端拼成「编码 + 半角空格 + 名称」(U+0020),用户照抄进模板 时,中文输入法全角模式敲出的空格是 U+3000、从网页复制偶尔带 U+00A0。几串肉眼 一模一样、码点不同,精确匹配落空,于是出现「浏览器复制的能导入、自己手写的报 不存在」——分类名一个字没差,差的是那个看不见的空格。 - 匹配前用 normalizeWhitespace 把各种空白(半角、全角、NBSP、制表符)压成单个 半角空格并去首尾;索引键与查询值同口径归一化,两边一致 - 只填空白的单元格现在报「大类/小类不能为空」,而不是「不存在或已停用」——它本来 就是空的 验证:新增表驱动用例覆盖 3 种变体(全角空格、不换行空格、连续半角空格),修复前 3 个全部失败、修复后全部通过;go build ./...、go vet ./internal/...、 go test ./... 全部通过。
325 lines
14 KiB
Go
325 lines
14 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是
|
||
// 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)
|
||
}
|
||
})
|
||
}
|
||
}
|