material_manage_system/internal/service/material_import.go
文睿 张 e853846070 feat: 物料导入校验与事务入库编排
- 新增 MaterialImportService.Import:解析 → 逐行校验 → 事务入库 → 写导入记录
- 校验覆盖名称/规格长度、大类小类归属、单位名称或编码匹配、小类编码规则存在性
- 同一小类的规则只查一次,避免批量行数放大成逐行往返
- 校验失败整批不入库并返回逐行明细;入库失败整体回滚
- 导入记录成功失败都写,失败原因只存前 5 行行号摘要,明细不落库
2026-10-10 15:33:13 +08:00

285 lines
9.0 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
}
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)
}