feat: 物料导入校验与事务入库编排
- 新增 MaterialImportService.Import:解析 → 逐行校验 → 事务入库 → 写导入记录 - 校验覆盖名称/规格长度、大类小类归属、单位名称或编码匹配、小类编码规则存在性 - 同一小类的规则只查一次,避免批量行数放大成逐行往返 - 校验失败整批不入库并返回逐行明细;入库失败整体回滚 - 导入记录成功失败都写,失败原因只存前 5 行行号摘要,明细不落库
This commit is contained in:
parent
0e3b1735a5
commit
e853846070
284
internal/service/material_import.go
Normal file
284
internal/service/material_import.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
)
|
||||
|
||||
// MaterialImportService 负责一次物料批量导入:解析、逐行校验、事务入库、写记录。
|
||||
// 首版只支持新增;校验阶段不生成编码,编码在入库事务里按小类规则分配。
|
||||
type MaterialImportService struct {
|
||||
materialRepo *repository.MaterialRepo
|
||||
masterRepo *repository.MasterDataRepository
|
||||
logRepo *repository.MaterialLogRepo
|
||||
importRepo *repository.MaterialImportLogRepo
|
||||
}
|
||||
|
||||
func NewMaterialImportService() *MaterialImportService {
|
||||
return &MaterialImportService{
|
||||
materialRepo: repository.NewMaterialRepo(),
|
||||
masterRepo: repository.NewMasterDataRepository(),
|
||||
logRepo: repository.NewMaterialLogRepo(),
|
||||
importRepo: repository.NewMaterialImportLogRepo(),
|
||||
}
|
||||
}
|
||||
|
||||
// Import 处理一次上传。返回值约定:
|
||||
// - 校验未通过:返回 result(含逐行错误)与 nil error,前端按明细展示;
|
||||
// - 模板或文件不合法:返回 nil 与错误,前端提示原因;
|
||||
// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
|
||||
//
|
||||
// 三种情况都会写一条导入记录。
|
||||
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
|
||||
if organizationID <= 0 {
|
||||
return nil, ErrInvalidOrganizationID
|
||||
}
|
||||
|
||||
rows, err := ParseMaterialImportFile(r)
|
||||
if err != nil {
|
||||
s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
materials, importErrors, err := s.validateRows(organizationID, rows)
|
||||
if err != nil {
|
||||
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(importErrors) > 0 {
|
||||
result := &model.MaterialImportResult{
|
||||
TotalRows: len(rows),
|
||||
SuccessRows: 0,
|
||||
FailedRows: len(importErrors),
|
||||
Errors: importErrors,
|
||||
}
|
||||
s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows,
|
||||
model.MaterialImportStatusFailed, summarizeImportErrors(importErrors))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := s.persist(organizationID, operator, materials); err != nil {
|
||||
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &model.MaterialImportResult{
|
||||
TotalRows: len(rows),
|
||||
SuccessRows: len(materials),
|
||||
FailedRows: 0,
|
||||
Errors: []model.MaterialImportError{},
|
||||
}
|
||||
s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
|
||||
// 错误;逐行的问题以 importErrors 返回,两者互斥。
|
||||
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) {
|
||||
categories, err := s.masterRepo.ListCategories(organizationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
units, err := s.masterRepo.ListUnits(organizationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
majors := make(map[string]*model.MaterialCategory)
|
||||
minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名
|
||||
for i := range categories {
|
||||
category := &categories[i]
|
||||
if category.Status != "active" {
|
||||
continue
|
||||
}
|
||||
if category.ParentID == nil {
|
||||
majors[category.Name] = category
|
||||
continue
|
||||
}
|
||||
minors[minorCategoryKey(*category.ParentID, category.Name)] = category
|
||||
}
|
||||
unitByName := make(map[string]*model.Unit)
|
||||
unitByCode := make(map[string]*model.Unit)
|
||||
for i := range units {
|
||||
unit := &units[i]
|
||||
if unit.Status != "active" {
|
||||
continue
|
||||
}
|
||||
// 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果
|
||||
if _, exists := unitByName[unit.Name]; !exists {
|
||||
unitByName[unit.Name] = unit
|
||||
}
|
||||
if _, exists := unitByCode[unit.Code]; !exists {
|
||||
unitByCode[unit.Code] = unit
|
||||
}
|
||||
}
|
||||
|
||||
materials := make([]*model.Material, 0, len(rows))
|
||||
importErrors := make([]model.MaterialImportError, 0)
|
||||
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
|
||||
// 逐行去查会把一次导入放大成两万次往返。
|
||||
ruleAvailable := make(map[int64]bool)
|
||||
|
||||
for _, row := range rows {
|
||||
reasons := make([]string, 0, 3)
|
||||
|
||||
name := row.Name
|
||||
if name == "" {
|
||||
reasons = append(reasons, "物料名称不能为空")
|
||||
} else if len([]rune(name)) > 200 {
|
||||
reasons = append(reasons, "物料名称超过 200 字")
|
||||
}
|
||||
if len([]rune(row.Spec)) > 200 {
|
||||
reasons = append(reasons, "规格超过 200 字")
|
||||
}
|
||||
|
||||
major := majors[row.MajorName]
|
||||
if row.MajorName == "" {
|
||||
reasons = append(reasons, "大类不能为空")
|
||||
} else if major == nil {
|
||||
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName))
|
||||
}
|
||||
|
||||
var minor *model.MaterialCategory
|
||||
if row.MinorName == "" {
|
||||
reasons = append(reasons, "小类不能为空")
|
||||
} else if major == nil {
|
||||
// 大类已经报错,小类无法定位,不重复报
|
||||
} else {
|
||||
minor = minors[minorCategoryKey(major.ID, row.MinorName)]
|
||||
if minor == nil {
|
||||
reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName))
|
||||
}
|
||||
}
|
||||
|
||||
// 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配,
|
||||
// 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。
|
||||
unitName := row.Unit
|
||||
if unitName == "" {
|
||||
unitName = "个"
|
||||
}
|
||||
unit := unitByName[unitName]
|
||||
if unit == nil {
|
||||
unit = unitByCode[unitName]
|
||||
}
|
||||
if unit == nil {
|
||||
if row.Unit == "" {
|
||||
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
|
||||
} else {
|
||||
reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit))
|
||||
}
|
||||
}
|
||||
|
||||
if minor != nil {
|
||||
available, checked := ruleAvailable[minor.ID]
|
||||
if !checked {
|
||||
rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
available = rule != nil
|
||||
ruleAvailable[minor.ID] = available
|
||||
}
|
||||
if !available {
|
||||
reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName))
|
||||
}
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
importErrors = append(importErrors, model.MaterialImportError{
|
||||
Row: row.Row,
|
||||
Data: row.Data(),
|
||||
Reason: strings.Join(reasons, ";"),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var baseUnitID *int64
|
||||
if unit != nil {
|
||||
id := unit.ID
|
||||
baseUnitID = &id
|
||||
}
|
||||
categoryID := minor.ID
|
||||
materials = append(materials, &model.Material{
|
||||
OrganizationID: organizationID,
|
||||
Name: name,
|
||||
Spec: row.Spec,
|
||||
CategoryID: &categoryID,
|
||||
BaseUnitID: baseUnitID,
|
||||
Unit: unitName,
|
||||
Status: "active",
|
||||
Version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
return materials, importErrors, nil
|
||||
}
|
||||
|
||||
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
||||
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error {
|
||||
tx, err := repository.BeginTx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logs := make([]*model.MaterialLog, 0, len(materials))
|
||||
for _, material := range materials {
|
||||
logs = append(logs, &model.MaterialLog{
|
||||
OperationType: "CREATE",
|
||||
MaterialID: material.ID,
|
||||
Operator: operator,
|
||||
Description: "批量导入创建",
|
||||
})
|
||||
}
|
||||
if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
|
||||
// 变成失败,所以这里吞掉错误。
|
||||
func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) {
|
||||
if len([]rune(reason)) > 1000 {
|
||||
reason = string([]rune(reason)[:1000])
|
||||
}
|
||||
_ = s.importRepo.Create(&model.MaterialImportLog{
|
||||
OrganizationID: organizationID,
|
||||
Operator: operator,
|
||||
FileName: fileName,
|
||||
TotalRows: total,
|
||||
SuccessRows: success,
|
||||
FailedRows: failed,
|
||||
Status: status,
|
||||
FailureReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。
|
||||
func summarizeImportErrors(importErrors []model.MaterialImportError) string {
|
||||
rows := make([]string, 0, 5)
|
||||
for i, item := range importErrors {
|
||||
if i == 5 {
|
||||
break
|
||||
}
|
||||
rows = append(rows, fmt.Sprintf("%d", item.Row))
|
||||
}
|
||||
summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、"))
|
||||
if len(importErrors) > 5 {
|
||||
summary += " 等"
|
||||
}
|
||||
return summary + ")"
|
||||
}
|
||||
|
||||
func minorCategoryKey(majorID int64, minorName string) string {
|
||||
return fmt.Sprintf("%d\x00%s", majorID, minorName)
|
||||
}
|
||||
197
internal/service/material_import_test.go
Normal file
197
internal/service/material_import_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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,再分批插入。
|
||||
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), "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, "")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user