316 lines
13 KiB
Go
316 lines
13 KiB
Go
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 = 10000
|
||
// MaterialImportMaxBytes 是上传文件大小上限。
|
||
MaterialImportMaxBytes = 1 << 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,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
||
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])
|
||
}
|