界面上的分类显示名由前端拼成「编码 + 半角空格 + 名称」(U+0020),用户照抄进模板 时,中文输入法全角模式敲出的空格是 U+3000、从网页复制偶尔带 U+00A0。几串肉眼 一模一样、码点不同,精确匹配落空,于是出现「浏览器复制的能导入、自己手写的报 不存在」——分类名一个字没差,差的是那个看不见的空格。 - 匹配前用 normalizeWhitespace 把各种空白(半角、全角、NBSP、制表符)压成单个 半角空格并去首尾;索引键与查询值同口径归一化,两边一致 - 只填空白的单元格现在报「大类/小类不能为空」,而不是「不存在或已停用」——它本来 就是空的 验证:新增表驱动用例覆盖 3 种变体(全角空格、不换行空格、连续半角空格),修复前 3 个全部失败、修复后全部通过;go build ./...、go vet ./internal/...、 go test ./... 全部通过。
319 lines
11 KiB
Go
319 lines
11 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, 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([]*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 字")
|
||
}
|
||
|
||
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, &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)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|