Compare commits

..

2 Commits

8 changed files with 397 additions and 117 deletions

View File

@ -397,12 +397,12 @@
**响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。 **响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。
模板含两张工作表: 模板含两张工作表,按以下顺序排列,打开时默认显示「填写说明」:
| 工作表 | 内容 | | 工作表 | 内容 |
|--------|------| |--------|------|
| 填写说明 | 字段、是否必填、示例、填写规则 | | 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 |
| 物料数据 | 第 1 行是表头,数据从第 2 行开始 | | 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 |
`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**: `物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:

View File

@ -324,6 +324,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"strings"
"testing" "testing"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
@ -493,6 +494,26 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
t.Fatalf("open template: %v", err) t.Fatalf("open template: %v", err)
} }
defer f.Close() defer f.Close()
sheets := f.GetSheetList()
if len(sheets) != 2 || sheets[0] != MaterialImportSheetGuide || sheets[1] != MaterialImportSheetData {
t.Fatalf("工作表顺序 = %v,期望先说明后填表", sheets)
}
if f.GetActiveSheetIndex() != 0 {
t.Fatal("打开模板时应先显示填写说明")
}
guide, err := f.GetRows(MaterialImportSheetGuide)
if err != nil {
t.Fatal(err)
}
var guideText strings.Builder
for _, row := range guide {
guideText.WriteString(strings.Join(row, " "))
}
for _, rule := range []string{"编码 名称", "5MB", "20000", "整批", "重复", "库存", "第 2 行"} {
if !strings.Contains(guideText.String(), rule) {
t.Fatalf("填写说明缺少规则:%s", rule)
}
}
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 { if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
t.Fatal("模板缺少「物料数据」工作表") t.Fatal("模板缺少「物料数据」工作表")
@ -508,8 +529,10 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
t.Fatalf("表头 = %v", headers) t.Fatalf("表头 = %v", headers)
} }
// 模板必须能被自己的解析器接受(表头一致) // 模板必须能被自己的解析器接受(表头一致)
if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil { if rows, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
t.Fatalf("模板无法被解析器接受: %v", err) t.Fatalf("模板无法被解析器接受: %v", err)
} else if len(rows) != 0 {
t.Fatal("数据页不应包含会被实际导入的示例物料")
} }
} }
@ -590,66 +613,85 @@ var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 // 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 // BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
func BuildMaterialImportTemplate() ([]byte, error) { func BuildMaterialImportTemplate() ([]byte, error) {
f := excelize.NewFile() f := excelize.NewFile()
defer f.Close() defer f.Close()
if err := f.SetSheetName("Sheet1", MaterialImportSheetGuide); err != nil {
if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil {
return nil, err return nil, err
} }
if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { if _, err := f.NewSheet(MaterialImportSheetData); err != nil {
return nil, err return nil, err
} }
// 数据表放在最前面,用户打开文件即停在填写页。 f.SetActiveSheet(0)
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil {
return nil, err
}
f.SetActiveSheet(idx)
guide := [][]string{ rules := [][]string{
{"字段", "是否必填", "示例", "填写规则"}, {"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
{"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
{"规格", "选填", "M8x30", "最长 200 字"}, {"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, {"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, {"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
{"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"},
{"", "", "", ""},
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
} }
for r, row := range guide { sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
for c, value := range row { notes := map[int]string{
cell, err := excelize.CoordinatesToCellName(c+1, r+1) 2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
if err != nil { 13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
return nil, err 14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
} 15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil { 16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
return nil, err 19: "文件限制:仅支持 .xlsx,大小不超过 5MB;非空数据最多 20000 行,表头和全空行不计数。",
} 20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
} 21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
} }
for i := range guide[0] { for row, text := range sections {
cell, _ := excelize.CoordinatesToCellName(i+1, 1) if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
_ = 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 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 { if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
return nil, err return nil, err
} }
if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil {
return nil, err
}
} }
_ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18) if err := styleMaterialImportTemplate(f); err != nil {
return nil, err
}
buf, err := f.WriteToBuffer() buf, err := f.WriteToBuffer()
if err != nil { if err != nil {
return nil, err return nil, err
@ -657,13 +699,75 @@ func BuildMaterialImportTemplate() ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。 func styleMaterialImportTemplate(f *excelize.File) error {
func guideHeaderStyle(f *excelize.File) int { styles := []excelize.Style{
style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) {Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
if err != nil { {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"}},
return 0 {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}},
} }
return style 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, "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
}
}
for i, width := range []float64{20, 12, 24, 34, 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, 26} {
col, _ := excelize.ColumnNumberToName(i + 1)
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
return err
}
}
return f.SetPanes(MaterialImportSheetData, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
} }
// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。 // ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。

View File

@ -192,11 +192,11 @@
**测试内容:** 验证物料分类、计量单位和编码规则能够配置,并被新增物料流程正确使用。 **测试内容:** 验证物料分类、计量单位和编码规则能够配置,并被新增物料流程正确使用。
**前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的分类编码、单位编码和编码前缀。 **前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的大类和小类编码、单位编码。
**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(例如前缀 `RT`、流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。 **操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。
**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码(例如 `RT0001`、`RT0002`),编码不重复,物料详情显示所选分类和单位。 **判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料详情显示所选分类和单位。
### TC-19 供应商和客户资料维护 ### TC-19 供应商和客户资料维护
@ -214,7 +214,7 @@
- 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。 - 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。
- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。 - 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。
- 每个文件使用独立的测试物料名称,执行前记录物料列表、规则下一流水号和库存基线。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。 - 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。
### TC-20 下载导入模板 ### TC-20 下载导入模板
@ -222,17 +222,17 @@
**操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。 **操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。
**判断指标:** 文件可正常打开,包含「物料数据」和「填写说明」;表头为「物料名称、规格、单位、大类、小类」。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成。 **判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复导入提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成;五列按文本格式填写,前导零不会因数字格式被去掉。
### TC-21 合法数据导入及编码生成 ### TC-21 合法数据导入及编码生成
**测试内容:** 验证物料新增、列表刷新及编码连续性。 **测试内容:** 验证物料新增、列表刷新及编码连续性。
**前置数据:** 专用小类编码规则前缀为 `RTIMP`、流水号 4 位、下一流水号为 1,且无已占用编码。 **前置数据:** 专用小类编码规则前缀按约定取该小类编码(含大类字母,如大类 `A` 下的小类 `A01`),流水号 4 位、下一流水号为 1,且无已占用编码。
**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,检查库存和流水。 **操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。
**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;编码分别为 `RTIMP0001`、`RTIMP0002`,日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为 `RTIMP0003`。导入结果刷新后仍可查询,原有库存及库存流水不变。 **判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。
### TC-22 分类写法与选填字段兼容 ### TC-22 分类写法与选填字段兼容
@ -246,9 +246,9 @@
**测试内容:** 验证字段错误提示以及合法行不会被部分导入。 **测试内容:** 验证字段错误提示以及合法行不会被部分导入。
**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则和库存基线。将超长字段改为 200 字且修正其余错误后重传。 **操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字且修正其余错误后重传。
**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则和库存不变;全部修正后可成功导入,200 字的名称和规格可接受。 **判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。
### TC-24 错误分页、导出与修正重传 ### TC-24 错误分页、导出与修正重传

View File

@ -510,6 +510,14 @@ async function handleSave() {
ElMessage.warning('请填写物料名称') ElMessage.warning('请填写物料名称')
return return
} }
if (Array.from(form.name).length > 200) {
ElMessage.warning('物料名称过长,请小于200字')
return
}
if (Array.from(form.spec || '').length > 200) {
ElMessage.warning('物料规格过长,请小于200字')
return
}
saveLoading.value = true saveLoading.value = true
try { try {
const formData = toMaterialPayload(form) const formData = toMaterialPayload(form)

View File

@ -332,7 +332,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
material, err := h.materialSvc.Create(&req, operator) material, err := h.materialSvc.Create(&req, operator)
if err != nil { if err != nil {
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) { if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
@ -371,7 +371,7 @@ func (h *Handler) UpdateMaterial(c *gin.Context) {
material, err := h.materialSvc.Update(orgID, id, &req, operator) material, err := h.materialSvc.Update(orgID, id, &req, operator)
if err != nil { if err != nil {
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) { if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }

View File

@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"unicode/utf8"
"github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/internal/repository"
@ -11,6 +12,12 @@ import (
var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型") var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型")
var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字")
var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字")
const maxMaterialNameLength = 200
const maxMaterialSpecLength = 200
// ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司, // ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司,
// 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。 // 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。
var ErrInvalidMaterialUnit = errors.New("请选择有效的单位") var ErrInvalidMaterialUnit = errors.New("请选择有效的单位")
@ -65,6 +72,12 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
if organizationID <= 0 { if organizationID <= 0 {
return nil, ErrInvalidOrganizationID return nil, ErrInvalidOrganizationID
} }
if utf8.RuneCountInString(req.Name) > maxMaterialNameLength {
return nil, ErrMaterialNameTooLong
}
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
return nil, ErrMaterialSpecTooLong
}
if err := s.validateCategory(req.CategoryID, organizationID); err != nil { if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
return nil, err return nil, err
} }
@ -184,6 +197,12 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
if err != nil { if err != nil {
return nil, err return nil, err
} }
if utf8.RuneCountInString(req.Name) > maxMaterialNameLength {
return nil, ErrMaterialNameTooLong
}
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
return nil, ErrMaterialSpecTooLong
}
if req.CategoryID == nil || *req.CategoryID <= 0 { if req.CategoryID == nil || *req.CategoryID <= 0 {
return nil, ErrInvalidMaterialCategory return nil, ErrInvalidMaterialCategory
} }

View File

@ -35,66 +35,85 @@ var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 // 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 // BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
func BuildMaterialImportTemplate() ([]byte, error) { func BuildMaterialImportTemplate() ([]byte, error) {
f := excelize.NewFile() f := excelize.NewFile()
defer f.Close() defer f.Close()
if err := f.SetSheetName("Sheet1", MaterialImportSheetGuide); err != nil {
if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil {
return nil, err return nil, err
} }
if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { if _, err := f.NewSheet(MaterialImportSheetData); err != nil {
return nil, err return nil, err
} }
// 数据表放在最前面,用户打开文件即停在填写页。 f.SetActiveSheet(0)
idx, err := f.GetSheetIndex(MaterialImportSheetData)
if err != nil {
return nil, err
}
f.SetActiveSheet(idx)
guide := [][]string{ rules := [][]string{
{"字段", "是否必填", "示例", "填写规则"}, {"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
{"物料名称", "必填", "内六角螺栓", "最长 200 字"}, {"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
{"规格", "选填", "M8x30", "最长 200 字"}, {"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
{"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, {"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
{"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, {"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
{"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"},
{"", "", "", ""},
{"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"},
} }
for r, row := range guide { sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
for c, value := range row { notes := map[int]string{
cell, err := excelize.CoordinatesToCellName(c+1, r+1) 2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
if err != nil { 13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
return nil, err 14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
} 15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil { 16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
return nil, err 19: "文件限制:仅支持 .xlsx,大小不超过 5MB;非空数据最多 20000 行,表头和全空行不计数。",
} 20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
} 21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
} }
for i := range guide[0] { for row, text := range sections {
cell, _ := excelize.CoordinatesToCellName(i+1, 1) if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
_ = 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 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 { if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
return nil, err return nil, err
} }
if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil {
return nil, err
}
} }
_ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18) if err := styleMaterialImportTemplate(f); err != nil {
return nil, err
}
buf, err := f.WriteToBuffer() buf, err := f.WriteToBuffer()
if err != nil { if err != nil {
return nil, err return nil, err
@ -102,13 +121,92 @@ func BuildMaterialImportTemplate() ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。 func styleMaterialImportTemplate(f *excelize.File) error {
func guideHeaderStyle(f *excelize.File) int { styles := []excelize.Style{
style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) {Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
if err != nil { {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"}},
return 0 {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}},
} }
return style 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 行号的数据行。 // ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。

View File

@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"io" "io"
"strings"
"testing" "testing"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
@ -174,6 +175,54 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
t.Fatalf("open template: %v", err) t.Fatalf("open template: %v", err)
} }
defer f.Close() defer f.Close()
sheets := f.GetSheetList()
if len(sheets) != 2 || sheets[0] != MaterialImportSheetGuide || sheets[1] != MaterialImportSheetData {
t.Fatalf("工作表顺序 = %v,期望先说明后填表", sheets)
}
if f.GetActiveSheetIndex() != 0 {
t.Fatal("打开模板时应先显示填写说明")
}
guide, err := f.GetRows(MaterialImportSheetGuide)
if err != nil {
t.Fatal(err)
}
var guideText strings.Builder
for _, row := range guide {
guideText.WriteString(strings.Join(row, " "))
}
for _, rule := range []string{"编码 名称", "5MB", "20000", "整批", "重复", "库存", "第 2 行"} {
if !strings.Contains(guideText.String(), rule) {
t.Fatalf("填写说明缺少规则:%s", rule)
}
}
styleAt := func(sheet, cell string) *excelize.Style {
t.Helper()
styleID, err := f.GetCellStyle(sheet, cell)
if err != nil {
t.Fatalf("读取 %s!%s 样式: %v", sheet, cell, err)
}
style, err := f.GetStyle(styleID)
if err != nil {
t.Fatalf("解析 %s!%s 样式: %v", sheet, cell, err)
}
return style
}
intro := styleAt(MaterialImportSheetGuide, "A2")
if intro.Font == nil || !intro.Font.Bold || !intro.Font.Italic || intro.Fill.Color[0] != "FFC000" || intro.Alignment == nil || intro.Alignment.Horizontal != "" || intro.Alignment.Vertical != "center" {
t.Fatalf("说明提示样式不匹配附件模板: %+v", intro)
}
field := styleAt(MaterialImportSheetGuide, "A6")
if field.Font == nil || !field.Font.Bold {
t.Fatalf("字段名应加粗: %+v", field)
}
required := styleAt(MaterialImportSheetGuide, "B6")
if required.Font == nil || !required.Font.Bold || required.Font.Color != "FF0000" {
t.Fatalf("必填标记应为红色粗体: %+v", required)
}
lastColumnWidth, err := f.GetColWidth(MaterialImportSheetData, "E")
if err != nil || lastColumnWidth != 13 {
t.Fatalf("物料数据 E 列宽 = %v, err = %v, want 13", lastColumnWidth, err)
}
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 { if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
t.Fatal("模板缺少「物料数据」工作表") t.Fatal("模板缺少「物料数据」工作表")
@ -189,8 +238,10 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
t.Fatalf("表头 = %v", headers) t.Fatalf("表头 = %v", headers)
} }
// 模板必须能被自己的解析器接受(表头一致) // 模板必须能被自己的解析器接受(表头一致)
if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil { if rows, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
t.Fatalf("模板无法被解析器接受: %v", err) t.Fatalf("模板无法被解析器接受: %v", err)
} else if len(rows) != 0 {
t.Fatal("数据页不应包含会被实际导入的示例物料")
} }
} }