material_manage_system/internal/service/material_import_excel.go

218 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
// 较大的工作表 XML 使用临时文件,避免压缩后很小的工作簿整表驻留内存。
f, err := excelize.OpenReader(r, excelize.Options{UnzipXMLSizeLimit: 1 << 20})
if err != nil {
if f != nil {
_ = f.Close()
}
return nil, ErrImportTemplateInvalid
}
defer f.Close()
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil || idx < 0 {
return nil, ErrImportTemplateInvalid
}
iterator, err := f.Rows(MaterialImportSheetData)
if err != nil {
return nil, err
}
defer iterator.Close()
if !iterator.Next() {
return nil, ErrImportTemplateInvalid
}
header, err := iterator.Columns()
if err != nil {
return nil, err
}
columns := materialImportColumnIndex(header)
for _, required := range materialImportRequiredHeaders {
if _, ok := columns[required]; !ok {
return nil, ErrImportTemplateInvalid
}
}
rows := make([]model.MaterialImportRow, 0, 256)
rowNumber := 1
for iterator.Next() {
rowNumber++
raw, err := iterator.Columns()
if err != nil {
return nil, err
}
row := model.MaterialImportRow{
Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
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
}
if len(rows) == MaterialImportMaxRows {
return nil, ErrImportTooManyRows
}
rows = append(rows, row)
}
if err := iterator.Error(); err != nil {
return nil, err
}
if err := iterator.Close(); err != nil {
return nil, err
}
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])
}