material_manage_system/internal/service/material_import_excel.go

316 lines
13 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", MaterialImportSheetGuide); err != nil {
return nil, err
}
if _, err := f.NewSheet(MaterialImportSheetData); err != nil {
return nil, err
}
f.SetActiveSheet(0)
rules := [][]string{
{"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
{"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
}
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
notes := map[int]string{
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
19: "文件限制:仅支持 .xlsx,大小不超过 5MB;非空数据最多 20000 行,表头和全空行不计数。",
20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
}
for row, text := range sections {
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
return nil, err
}
}
for row, text := range notes {
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
return nil, err
}
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
return nil, err
}
}
guideRows := append([][]string{{"字段", "是否必填", "示例", "填写规则"}}, rules...)
for i, row := range guideRows {
for j, text := range row {
cell, _ := excelize.CoordinatesToCellName(j+1, i+5)
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
return nil, err
}
}
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("E%d", i+5)); err != nil {
return nil, err
}
}
sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "机械结构类", "标准件"}}
for i, row := range sampleRows {
for j, text := range row {
cell, _ := excelize.CoordinatesToCellName(j+1, i+26)
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
return nil, err
}
}
}
for i, header := range materialImportHeaders {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
return nil, err
}
}
if err := styleMaterialImportTemplate(f); err != nil {
return nil, err
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func styleMaterialImportTemplate(f *excelize.File) error {
styles := []excelize.Style{
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 20, Bold: true, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"17365D"}}, Alignment: &excelize.Alignment{Vertical: "center"}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 12, Bold: true, Color: "17365D"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E8EFF7"}}, Alignment: &excelize.Alignment{Vertical: "center"}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"345D87"}}, Alignment: &excelize.Alignment{Vertical: "center", Horizontal: "left", WrapText: true}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "1D4ED8"}, NumFmt: 49, Alignment: &excelize.Alignment{Vertical: "center", Horizontal: "left", WrapText: true}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Italic: true, Color: "334155"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FFC000"}}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "FF0000"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
}
ids := make([]int, len(styles))
for i := range styles {
id, err := f.NewStyle(&styles[i])
if err != nil {
return err
}
ids[i] = id
}
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E30", ids[0]); err != nil {
return err
}
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "E2", ids[5]); err != nil {
return err
}
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E1", ids[1]); err != nil {
return err
}
for _, row := range []int{4, 12, 18, 25} {
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[2]); err != nil {
return err
}
}
for _, row := range []int{5, 26} {
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[3]); err != nil {
return err
}
}
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A10", ids[6]); err != nil {
return err
}
for _, cell := range []string{"B6", "B9", "B10"} {
if err := f.SetCellStyle(MaterialImportSheetGuide, cell, cell, ids[7]); err != nil {
return err
}
}
for i, width := range []float64{20, 12, 24, 34} {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := f.SetColWidth(MaterialImportSheetGuide, col, col, width); err != nil {
return err
}
}
for row := 1; row <= 30; row++ {
height := 30.0
if row == 1 {
height = 42
}
if row >= 6 && row <= 10 {
height = 54
}
if row >= 19 && row <= 23 || row == 30 {
height = 42
}
if err := f.SetRowHeight(MaterialImportSheetGuide, row, height); err != nil {
return err
}
}
if err := f.SetColStyle(MaterialImportSheetData, "A:E", ids[4]); err != nil {
return err
}
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "E1", ids[3]); err != nil {
return err
}
if err := f.SetRowHeight(MaterialImportSheetData, 1, 32); err != nil {
return err
}
for i, width := range []float64{34, 28, 16, 26} {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
return err
}
}
if err := f.SetColWidth(MaterialImportSheetData, "E", "E", 13); err != nil {
return err
}
return f.SetPanes(MaterialImportSheetData, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
}
// 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])
}