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