界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板, 却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按 分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。 - 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖, 结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法 - 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在, 把排查方向带偏了 - 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」) 验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码); 另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。 go build ./...、go vet ./internal/...、go test ./... 全部通过。
197 lines
6.6 KiB
Go
197 lines
6.6 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 = 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])
|
||
}
|