318 lines
11 KiB
Go
318 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"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
|
||
}
|
||
|
||
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
|
||
type materialImportItem struct {
|
||
*model.Material
|
||
categoryLabel string
|
||
unitLabel string
|
||
}
|
||
|
||
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 {
|
||
return nil, err
|
||
}
|
||
|
||
materials, importErrors, err := s.validateRows(organizationID, rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(importErrors) > 0 {
|
||
result := &model.MaterialImportResult{
|
||
TotalRows: len(rows),
|
||
SuccessRows: 0,
|
||
FailedRows: len(importErrors),
|
||
Errors: importErrors,
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
if err := s.persist(organizationID, operator, materials); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := &model.MaterialImportResult{
|
||
TotalRows: len(rows),
|
||
SuccessRows: len(materials),
|
||
FailedRows: 0,
|
||
Errors: []model.MaterialImportError{},
|
||
}
|
||
s.recordSuccessfulImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows)
|
||
return result, nil
|
||
}
|
||
|
||
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
|
||
// 错误;逐行的问题以 importErrors 返回,两者互斥。
|
||
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*materialImportItem, []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, normalizeWhitespace(alias), category)
|
||
}
|
||
continue
|
||
}
|
||
for _, alias := range aliases {
|
||
registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, normalizeWhitespace(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([]*materialImportItem, 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 字")
|
||
}
|
||
|
||
majorName := normalizeWhitespace(row.MajorName)
|
||
major := majors[majorName]
|
||
if majorName == "" {
|
||
reasons = append(reasons, "大类不能为空")
|
||
} else if major == nil {
|
||
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
|
||
}
|
||
|
||
minorName := normalizeWhitespace(row.MinorName)
|
||
var minor *model.MaterialCategory
|
||
if minorName == "" {
|
||
reasons = append(reasons, "小类不能为空")
|
||
} else if major == nil {
|
||
// 大类已经报错,小类无法定位,不重复报
|
||
} else {
|
||
minor = minors[minorCategoryKey(major.ID, 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, &materialImportItem{Material: &model.Material{
|
||
OrganizationID: organizationID,
|
||
Name: name,
|
||
Spec: row.Spec,
|
||
CategoryID: &categoryID,
|
||
BaseUnitID: baseUnitID,
|
||
Unit: unitName,
|
||
Status: "active",
|
||
Version: 1,
|
||
}, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
|
||
}
|
||
|
||
return materials, importErrors, nil
|
||
}
|
||
|
||
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
||
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
|
||
tx, err := repository.BeginTx()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
materialRows := make([]*model.Material, 0, len(materials))
|
||
for _, item := range materials {
|
||
materialRows = append(materialRows, item.Material)
|
||
}
|
||
if err := s.materialRepo.ImportTx(tx, organizationID, materialRows); err != nil {
|
||
return err
|
||
}
|
||
|
||
logs := make([]*model.MaterialLog, 0, len(materials))
|
||
for _, material := range materials {
|
||
newValue, err := json.Marshal(material.Material)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
spec := material.Spec
|
||
if spec == "" {
|
||
spec = "—"
|
||
}
|
||
logs = append(logs, &model.MaterialLog{
|
||
OperationType: "CREATE",
|
||
MaterialID: material.ID,
|
||
NewValue: string(newValue),
|
||
Operator: operator,
|
||
Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s",
|
||
material.Name, material.MaterialCode, material.MaterialCode, material.Name, spec,
|
||
material.categoryLabel, material.unitLabel, materialStatusLabel(material.Status)),
|
||
})
|
||
}
|
||
if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
|
||
// 变成失败,所以这里吞掉错误。
|
||
func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) {
|
||
_ = s.importRepo.Create(&model.MaterialImportLog{
|
||
OrganizationID: organizationID,
|
||
Operator: operator,
|
||
FileName: fileName,
|
||
TotalRows: total,
|
||
SuccessRows: success,
|
||
FailedRows: 0,
|
||
Status: model.MaterialImportStatusSuccess,
|
||
FailureReason: "",
|
||
})
|
||
}
|
||
|
||
func minorCategoryKey(majorID int64, minorName string) string {
|
||
return fmt.Sprintf("%d\x00%s", majorID, minorName)
|
||
}
|
||
|
||
// normalizeWhitespace 把文本里的各种空白(半角空格、全角空格 U+3000、不换行空格
|
||
// U+00A0、制表符等)统一压成单个半角空格,并去掉首尾。
|
||
//
|
||
// 界面上的分类显示名是前端拼出来的「编码 + 半角空格 + 名称」,用户照抄进模板时,
|
||
// 那个空格在中文输入法全角模式下会变成 U+3000、从网页复制偶尔带 U+00A0——两串肉眼
|
||
// 一模一样、码点不同,不归一化就成了「浏览器复制的能导入、自己手写的报不存在」。
|
||
func normalizeWhitespace(text string) string {
|
||
return strings.Join(strings.Fields(text), " ")
|
||
}
|
||
|
||
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
||
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
||
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
|
||
if key == "" {
|
||
return
|
||
}
|
||
if _, exists := index[key]; !exists {
|
||
index[key] = category
|
||
}
|
||
}
|