material_manage_system/internal/service/material_import_excel.go
文睿 张 f634e7cf07 fix: 物料导入分类匹配兼容界面上的「编码 名称」写法
界面上的分类一律显示成「编码 名称」(如 A 机械结构类),用户照此填进导入模板,
却报「大类「A 机械结构类」不存在或已停用」。分类其实存在且已启用,是后端只按
分类名称精确匹配,对不上带编码前缀的写法。单位早有名称/编码双匹配,分类漏了。

- 分类索引同时登记名称、编码、「编码 名称」三种写法。名称优先、键已占用时不覆盖,
  结果不随分类遍历顺序变化;停用分类仍在登记前被过滤,放宽的只是写法
- 报错文案补「请核对分类名称或编码」:原文案说「不存在或已停用」,而分类明明在,
  把排查方向带偏了
- 模板「填写说明」与 api.md 同步口径(原文写的是「分类按名称填写」)

验证:2 条新用例先跑成失败再转绿(「A 机械结构类」+「A01 标准件」、纯编码);
另加 1 条停用分类的边界用例,并做变异验证——临时关掉 status 过滤后它如期失败。
go build ./...、go vet ./internal/...、go test ./... 全部通过。
2026-10-10 17:32:15 +08:00

197 lines
6.6 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) {
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])
}