From d864294ddebf77ae579dcc7efce53adcf8b1f27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Mon, 12 Oct 2026 15:41:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(material-import):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF=E5=A1=AB=E5=86=99=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E9=A1=B5=EF=BC=8C=E5=90=8C=E6=AD=A5=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=81=E8=AE=A1=E5=88=92=E4=B8=8E=E5=9B=9E=E5=BD=92=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api.md | 6 +- .../plans/2026-10-10-material-bulk-import.md | 206 +++++++++++++----- docs/回归测试用例.md | 20 +- internal/service/material_import_excel.go | 198 ++++++++++++----- .../service/material_import_excel_test.go | 53 ++++- 5 files changed, 368 insertions(+), 115 deletions(-) diff --git a/docs/api.md b/docs/api.md index c12ce7f9..cde262b1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -397,12 +397,12 @@ **响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。 -模板含两张工作表: +模板含两张工作表,按以下顺序排列,打开时默认显示「填写说明」: | 工作表 | 内容 | |--------|------| -| 填写说明 | 字段、是否必填、示例、填写规则 | -| 物料数据 | 第 1 行是表头,数据从第 2 行开始 | +| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 | +| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 | `物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**: diff --git a/docs/superpowers/plans/2026-10-10-material-bulk-import.md b/docs/superpowers/plans/2026-10-10-material-bulk-import.md index e40b0a34..d4719488 100644 --- a/docs/superpowers/plans/2026-10-10-material-bulk-import.md +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -324,6 +324,7 @@ import ( "bytes" "errors" "io" + "strings" "testing" "github.com/xuri/excelize/v2" @@ -493,6 +494,26 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { t.Fatalf("open template: %v", err) } 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 { t.Fatal("模板缺少「物料数据」工作表") @@ -508,8 +529,10 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { 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) + } else if len(rows) != 0 { + t.Fatal("数据页不应包含会被实际导入的示例物料") } } @@ -590,66 +613,85 @@ var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类 // 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} -// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 +// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。 func BuildMaterialImportTemplate() ([]byte, error) { f := excelize.NewFile() defer f.Close() - - if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil { + if err := f.SetSheetName("Sheet1", MaterialImportSheetGuide); err != nil { return nil, err } - if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { + if _, err := f.NewSheet(MaterialImportSheetData); err != nil { return nil, err } - // 数据表放在最前面,用户打开文件即停在填写页。 - idx, err := f.GetSheetIndex(MaterialImportSheetData) - if err != nil { - return nil, err - } - f.SetActiveSheet(idx) + f.SetActiveSheet(0) - guide := [][]string{ - {"字段", "是否必填", "示例", "填写规则"}, - {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, - {"规格", "选填", "M8x30", "最长 200 字"}, - {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, - {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, - {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"}, - {"", "", "", ""}, - {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, + rules := [][]string{ + {"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"}, + {"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"}, + {"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"}, + {"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"}, + {"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"}, } - 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 - } - } + 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 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 { + 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 := 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() if err != nil { return nil, err @@ -657,13 +699,75 @@ func BuildMaterialImportTemplate() ([]byte, error) { 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 +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}}, } - 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 行号的数据行。 diff --git a/docs/回归测试用例.md b/docs/回归测试用例.md index 172cd3f8..87afb0ac 100644 --- a/docs/回归测试用例.md +++ b/docs/回归测试用例.md @@ -192,11 +192,11 @@ **测试内容:** 验证物料分类、计量单位和编码规则能够配置,并被新增物料流程正确使用。 -**前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的分类编码、单位编码和编码前缀。 +**前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的大类和小类编码、单位编码。 -**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(例如前缀 `RT`、流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。 +**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。 -**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码(例如 `RT0001`、`RT0002`),编码不重复,物料详情显示所选分类和单位。 +**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料详情显示所选分类和单位。 ### TC-19 供应商和客户资料维护 @@ -214,7 +214,7 @@ - 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。 - 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。 -- 每个文件使用独立的测试物料名称,执行前记录物料列表、规则下一流水号和库存基线。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。 +- 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。 ### TC-20 下载导入模板 @@ -222,17 +222,17 @@ **操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。 -**判断指标:** 文件可正常打开,包含「物料数据」和「填写说明」;表头为「物料名称、规格、单位、大类、小类」。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成。 +**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复导入提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成;五列按文本格式填写,前导零不会因数字格式被去掉。 ### TC-21 合法数据导入及编码生成 **测试内容:** 验证物料新增、列表刷新及编码连续性。 -**前置数据:** 专用小类编码规则前缀为 `RTIMP`、流水号 4 位、下一流水号为 1,且无已占用编码。 +**前置数据:** 专用小类编码规则前缀按约定取该小类编码(含大类字母,如大类 `A` 下的小类 `A01`),流水号 4 位、下一流水号为 1,且无已占用编码。 -**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,检查库存和流水。 +**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。 -**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;编码分别为 `RTIMP0001`、`RTIMP0002`,日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为 `RTIMP0003`。导入结果刷新后仍可查询,原有库存及库存流水不变。 +**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。 ### TC-22 分类写法与选填字段兼容 @@ -246,9 +246,9 @@ **测试内容:** 验证字段错误提示以及合法行不会被部分导入。 -**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则和库存基线。将超长字段改为 200 字且修正其余错误后重传。 +**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字且修正其余错误后重传。 -**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则和库存不变;全部修正后可成功导入,200 字的名称和规格可接受。 +**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。 ### TC-24 错误分页、导出与修正重传 diff --git a/internal/service/material_import_excel.go b/internal/service/material_import_excel.go index 4dea6597..c4864438 100644 --- a/internal/service/material_import_excel.go +++ b/internal/service/material_import_excel.go @@ -35,66 +35,85 @@ var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类 // 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} -// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 +// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。 func BuildMaterialImportTemplate() ([]byte, error) { f := excelize.NewFile() defer f.Close() - - if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil { + if err := f.SetSheetName("Sheet1", MaterialImportSheetGuide); err != nil { return nil, err } - if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { + if _, err := f.NewSheet(MaterialImportSheetData); err != nil { return nil, err } - // 数据表放在最前面,用户打开文件即停在填写页。 - idx, err := f.GetSheetIndex(MaterialImportSheetData) - if err != nil { - return nil, err - } - f.SetActiveSheet(idx) + f.SetActiveSheet(0) - guide := [][]string{ - {"字段", "是否必填", "示例", "填写规则"}, - {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, - {"规格", "选填", "M8x30", "最长 200 字"}, - {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, - {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称或编码"}, - {"小类", "必填", "标准件", "填该大类下已启用的小类名称或编码,且该小类必须已配置编码规则"}, - {"", "", "", ""}, - {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, + rules := [][]string{ + {"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"}, + {"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"}, + {"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"}, + {"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"}, + {"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"}, } - 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 - } - } + 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 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 { + 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 := 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() if err != nil { return nil, err @@ -102,13 +121,92 @@ func BuildMaterialImportTemplate() ([]byte, error) { 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 +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}}, } - 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 行号的数据行。 diff --git a/internal/service/material_import_excel_test.go b/internal/service/material_import_excel_test.go index 7465db7a..54342e95 100644 --- a/internal/service/material_import_excel_test.go +++ b/internal/service/material_import_excel_test.go @@ -5,6 +5,7 @@ import ( "bytes" "errors" "io" + "strings" "testing" "github.com/xuri/excelize/v2" @@ -174,6 +175,54 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { t.Fatalf("open template: %v", err) } 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 { t.Fatal("模板缺少「物料数据」工作表") @@ -189,8 +238,10 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { 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) + } else if len(rows) != 0 { + t.Fatal("数据页不应包含会被实际导入的示例物料") } }