界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板, 却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按 分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。 - 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖, 结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法 - 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在, 把排查方向带偏了 - 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」) 验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码); 另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。 go build ./...、go vet ./internal/...、go test ./... 全部通过。
307 lines
10 KiB
Go
307 lines
10 KiB
Go
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
|
||
}
|
||
// 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段
|
||
// 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排
|
||
// 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。
|
||
aliases := []string{category.Name, category.Code}
|
||
if category.Code != "" {
|
||
aliases = append(aliases, category.Code+" "+category.Name)
|
||
}
|
||
if category.ParentID == nil {
|
||
for _, alias := range aliases {
|
||
registerCategoryAlias(majors, alias, category)
|
||
}
|
||
continue
|
||
}
|
||
for _, alias := range aliases {
|
||
registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, alias), 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)
|
||
}
|
||
|
||
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
||
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
||
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
|
||
if key == "" {
|
||
return
|
||
}
|
||
if _, exists := index[key]; !exists {
|
||
index[key] = category
|
||
}
|
||
}
|