material_manage_system/internal/service/material_import.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

307 lines
10 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 (
"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
}
}