package service import ( "errors" "fmt" "io" "strings" "github.com/wmms/wmms/internal/model" "github.com/xuri/excelize/v2" ) const ( // MaterialImportSheetData 是填写数据的表名,「物料数据」。 MaterialImportSheetData = "物料数据" // MaterialImportSheetGuide 是填写说明的表名。 MaterialImportSheetGuide = "填写说明" // MaterialImportMaxRows 是数据区最大行数(不含表头)。 MaterialImportMaxRows = 20000 // MaterialImportMaxBytes 是上传文件大小上限。 MaterialImportMaxBytes = 5 << 20 ) var ( // ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。 ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板") // ErrImportTooManyRows 表示数据行数超出上限。 ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows) ) // materialImportHeaders 是生成模板时的列顺序(含选填列)。 var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"} // materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为 // 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} // BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 func BuildMaterialImportTemplate() ([]byte, error) { f := excelize.NewFile() defer f.Close() if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil { return nil, err } if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { return nil, err } // 数据表放在最前面,用户打开文件即停在填写页。 idx, err := f.GetSheetIndex(MaterialImportSheetData) if err != nil { return nil, err } f.SetActiveSheet(idx) guide := [][]string{ {"字段", "是否必填", "示例", "填写规则"}, {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"规格", "选填", "M8x30", "最长 200 字"}, {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"}, {"", "", "", ""}, {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, } for r, row := range guide { for c, value := range row { cell, err := excelize.CoordinatesToCellName(c+1, r+1) if err != nil { return nil, err } if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil { return nil, err } } } for i := range guide[0] { cell, _ := excelize.CoordinatesToCellName(i+1, 1) _ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f)) } _ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14) _ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24) for i, header := range materialImportHeaders { cell, err := excelize.CoordinatesToCellName(i+1, 1) if err != nil { return nil, err } if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil { return nil, err } if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil { return nil, err } } _ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18) buf, err := f.WriteToBuffer() if err != nil { return nil, err } return buf.Bytes(), nil } // guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。 func guideHeaderStyle(f *excelize.File) int { style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) if err != nil { return 0 } return style } // ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。 // 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列 // 或缺少「物料数据」表时才判定模板不合法。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { f, err := excelize.OpenReader(r) if err != nil { return nil, ErrImportTemplateInvalid } defer f.Close() idx, err := f.GetSheetIndex(MaterialImportSheetData) if err != nil || idx < 0 { return nil, ErrImportTemplateInvalid } all, err := f.GetRows(MaterialImportSheetData) if err != nil { return nil, err } if len(all) == 0 { return nil, ErrImportTemplateInvalid } columns := materialImportColumnIndex(all[0]) for _, required := range materialImportRequiredHeaders { if _, ok := columns[required]; !ok { return nil, ErrImportTemplateInvalid } } rows := make([]model.MaterialImportRow, 0, len(all)-1) for i, raw := range all[1:] { row := model.MaterialImportRow{ Row: i + 2, // Excel 行号:表头占第 1 行 Name: materialImportColumnValue(raw, columns, "物料名称"), Spec: materialImportColumnValue(raw, columns, "规格"), Unit: materialImportColumnValue(raw, columns, "单位"), MajorName: materialImportColumnValue(raw, columns, "大类"), MinorName: materialImportColumnValue(raw, columns, "小类"), } if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { continue } rows = append(rows, row) } if len(rows) > MaterialImportMaxRows { return nil, ErrImportTooManyRows } return rows, nil } // materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加 // 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。 func materialImportColumnIndex(header []string) map[string]int { index := make(map[string]int, len(header)) for i, cell := range header { name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*")) if name == "" { continue } if _, exists := index[name]; !exists { index[name] = i } } return index } // materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。 func materialImportColumnValue(row []string, columns map[string]int, name string) string { index, ok := columns[name] if !ok { return "" } return materialImportCell(row, index) } func materialImportCell(row []string, index int) string { if index >= len(row) { return "" } return strings.TrimSpace(row[index]) }