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 } }