material_manage_system/internal/service/material_test.go
文睿 张 616a4a3b1d feat: 物料增加初始单位,补充物料批量上传计划
- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致
- 单位下拉增加未选择时的默认提示
- init.sql 补充 30 个常用计量单位种子数据
- init.sql 新增 material_import_logs 表,为批量导入提供落库
- 新增物料批量导入实施计划文档,相关测试期望值同步更新
2026-10-10 14:15:49 +08:00

304 lines
11 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 (
"errors"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
)
func TestMaterialServiceRejectsInvalidCategory(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() {
database.DB = nil
db.Close()
})
database.DB = db
query := `SELECT EXISTS(
SELECT 1 FROM material_categories
WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active'
)`
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
categoryID := int64(12)
_, err = NewMaterialService().Create(&model.CreateMaterialRequest{
OrganizationID: 1,
MaterialCode: "MAT-001",
Name: "轴承",
CategoryID: &categoryID,
}, "tester")
if !errors.Is(err, ErrInvalidMaterialCategory) {
t.Fatalf("create error = %v, want ErrInvalidMaterialCategory", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
t.Cleanup(func() {
database.DB = nil
db.Close()
})
database.DB = db
query := getMaterialQuery
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
)
active, err := NewMaterialService().IsActive(1, 7)
if err != nil {
t.Fatalf("check material activity: %v", err)
}
if active {
t.Fatal("obsolete material must not be active")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceUpdatesWithoutChangingDisabledCategory(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
materialRows := func(name string) *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", name, nil, "", nil, 12, nil, "pcs", "active", 1, now, now)
}
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("原名称"))
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE organization_id = ? AND id = ?")).
WithArgs("新名称", int64(12), int64(1), int64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("新名称"))
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("UPDATE", int64(7), sqlmock.AnyArg(), sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
categoryID := int64(12)
material, err := NewMaterialService().Update(1, 7, &model.UpdateMaterialRequest{Name: "新名称", CategoryID: &categoryID}, "tester")
if err != nil {
t.Fatalf("update material with unchanged disabled category: %v", err)
}
if material.CategoryID == nil || *material.CategoryID != 12 || material.Name != "新名称" {
t.Fatalf("updated material = %+v, want category 12 and new name", material)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 以下常量必须与 repository 里的 SQL 字面量逐字一致,sqlmock 才匹配得上。
const (
activeSubcategoryQuery = `SELECT EXISTS(
SELECT 1 FROM material_categories
WHERE id = ? AND organization_id = ? AND parent_id IS NOT NULL AND status = 'active'
)`
lockCodeRuleQuery = `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`
codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)`
insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
getMaterialQuery = `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
category_id, base_unit_id, unit, status, version, created_at, updated_at
FROM materials WHERE organization_id = ? AND id = ?`
)
func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sql mock: %v", err)
}
database.DB = db
return mock, func() {
database.DB = nil
db.Close()
}
}
func expectActiveSubcategory(mock sqlmock.Sqlmock) {
mock.ExpectQuery(regexp.QuoteMeta(activeSubcategoryQuery)).WithArgs(int64(12), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
}
// expectLockedCodeRule 开始事务并锁住小类 12 上的编码规则;nextSequence 为 nil
// 表示该小类没有规则。
func expectLockedCodeRule(mock sqlmock.Sqlmock, nextSequence *int64) {
mock.ExpectBegin()
rows := sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})
if nextSequence != nil {
rows.AddRow(5, 1, 12, "A01", 4, *nextSequence)
}
mock.ExpectQuery(regexp.QuoteMeta(lockCodeRuleQuery)).WithArgs(int64(1), int64(12)).WillReturnRows(rows)
}
func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) {
mock.ExpectQuery(regexp.QuoteMeta(codeTakenQuery)).WithArgs(int64(1), code).
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(taken))
}
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
}
func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "个", "active", 1, now, now),
)
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
}
func createMaterialWithCode(t *testing.T, code string) (*model.Material, error) {
t.Helper()
categoryID := int64(12)
return NewMaterialService().Create(&model.CreateMaterialRequest{
OrganizationID: 1,
MaterialCode: code,
Name: "轴承",
CategoryID: &categoryID,
}, "tester")
}
func TestMaterialServiceAdvancesSequenceForAutoGeneratedCode(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
expectCodeTaken(mock, "A010201", false)
expectMaterialInsert(mock, "A010201")
mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(202), int64(5)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
expectCreatedMaterial(mock, "A010201")
material, err := createMaterialWithCode(t, "A010201")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "A010201" {
t.Fatalf("material code = %s, want A010201", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceSkipsTakenSequenceNumbers(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
expectCodeTaken(mock, "A010201", true)
expectCodeTaken(mock, "A010202", false)
expectMaterialInsert(mock, "A010202")
mock.ExpectExec(regexp.QuoteMeta(advanceSequenceStmt)).WithArgs(int64(203), int64(5)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
expectCreatedMaterial(mock, "A010202")
material, err := createMaterialWithCode(t, "A010201")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "A010202" {
t.Fatalf("material code = %s, want A010202", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceKeepsManuallyEditedCodeWithoutAdvancingSequence(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
// 手改过的编码不等于规则产物,既不该探测占用,也不该推进号段。
expectMaterialInsert(mock, "CUSTOM-001")
mock.ExpectCommit()
expectCreatedMaterial(mock, "CUSTOM-001")
material, err := createMaterialWithCode(t, "CUSTOM-001")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "CUSTOM-001" {
t.Fatalf("material code = %s, want CUSTOM-001", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceCreatesMaterialWhenCategoryHasNoCodeRule(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, nil)
expectMaterialInsert(mock, "MANUAL-009")
mock.ExpectCommit()
expectCreatedMaterial(mock, "MANUAL-009")
material, err := createMaterialWithCode(t, "MANUAL-009")
if err != nil {
t.Fatalf("create material: %v", err)
}
if material.MaterialCode != "MANUAL-009" {
t.Fatalf("material code = %s, want MANUAL-009", material.MaterialCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
mock, cleanup := newMaterialServiceMock(t)
t.Cleanup(cleanup)
next := int64(201)
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
mock.ExpectRollback()
_, err := createMaterialWithCode(t, "CUSTOM-001")
if !errors.Is(err, repository.ErrDuplicateMaterialCode) {
t.Fatalf("create error = %v, want ErrDuplicateMaterialCode", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}