package service import ( "archive/zip" "bytes" "errors" "io" "strings" "testing" "github.com/xuri/excelize/v2" ) func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) { dataRows := make([][]string, MaterialImportMaxRows+1) for i := range dataRows { dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"} } data := buildTestWorkbook(t, dataRows) reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatal(err) } var changed bytes.Buffer writer := zip.NewWriter(&changed) for _, entry := range reader.File { source, err := entry.Open() if err != nil { t.Fatal(err) } content, err := io.ReadAll(source) source.Close() if err != nil { t.Fatal(err) } if entry.Name == "xl/worksheets/sheet1.xml" { // 超限行之后故意放非法 XML,证明解析器不会再读取后续行。 content = bytes.Replace(content, []byte(""), []byte(``), 1) } target, err := writer.Create(entry.Name) if err != nil { t.Fatal(err) } if _, err := target.Write(content); err != nil { t.Fatal(err) } } if err := writer.Close(); err != nil { t.Fatal(err) } rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes())) if !errors.Is(err, ErrImportTooManyRows) || rows != nil { t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err) } } func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) { dataRows := make([][]string, MaterialImportMaxRows) for i := range dataRows { dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"} } rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows))) if err != nil || len(rows) != MaterialImportMaxRows { t.Fatalf("rows=%d, err=%v", len(rows), err) } } func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) { data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, {"", "", "", "", ""}, // 整行空,应被跳过 {"急停按钮", "XB2", "个", "电气元器件类", "成品件"}, }) rows, err := ParseMaterialImportFile(bytes.NewReader(data)) if err != nil { t.Fatalf("parse: %v", err) } if len(rows) != 2 { t.Fatalf("rows = %d, want 2", len(rows)) } if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" { t.Fatalf("first row = %+v", rows[0]) } // 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行 if rows[1].Row != 4 || rows[1].Name != "急停按钮" { t.Fatalf("second row = %+v", rows[1]) } } func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) { // 名称填了、其余为空:不是空行,必须保留交给校验去报错 data := buildTestWorkbook(t, [][]string{ {"只有名称", "", "", "", ""}, }) rows, err := ParseMaterialImportFile(bytes.NewReader(data)) if err != nil { t.Fatalf("parse: %v", err) } if len(rows) != 1 || rows[0].Name != "只有名称" { t.Fatalf("rows = %+v", rows) } } func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) { // 只有「物料名称」和「规格」,缺必填的大类、小类 data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{ {"内六角螺栓", "M8x30"}, }) _, err := ParseMaterialImportFile(bytes.NewReader(data)) if !errors.Is(err, ErrImportTemplateInvalid) { t.Fatalf("err = %v, want ErrImportTemplateInvalid", err) } } func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) { // 删掉「规格」,并把「单位」挪到「物料名称」前面 headers := []string{"单位", "物料名称", "大类", "小类"} data := buildTestWorkbookWithHeaders(t, headers, [][]string{ {"个", "内六角螺栓", "机械结构类", "标准件"}, }) rows, err := ParseMaterialImportFile(bytes.NewReader(data)) if err != nil { t.Fatalf("parse: %v", err) } if len(rows) != 1 { t.Fatalf("rows = %d, want 1", len(rows)) } if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" { t.Fatalf("row = %+v", rows[0]) } if rows[0].Spec != "" { t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec) } } func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) { headers := []string{"物料名称", "备注", "大类", "小类"} data := buildTestWorkbookWithHeaders(t, headers, [][]string{ {"内六角螺栓", "与导入无关", "机械结构类", "标准件"}, }) rows, err := ParseMaterialImportFile(bytes.NewReader(data)) if err != nil { t.Fatalf("parse: %v", err) } if len(rows) != 1 || rows[0].Name != "内六角螺栓" { t.Fatalf("rows = %+v", rows) } } func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) { rows := make([][]string, 0, MaterialImportMaxRows+1) for i := 0; i < MaterialImportMaxRows+1; i++ { rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"}) } data := buildTestWorkbook(t, rows) _, err := ParseMaterialImportFile(bytes.NewReader(data)) if !errors.Is(err, ErrImportTooManyRows) { t.Fatalf("err = %v, want ErrImportTooManyRows", err) } } func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { data, err := BuildMaterialImportTemplate() if err != nil { t.Fatalf("build template: %v", err) } f, err := excelize.OpenReader(bytes.NewReader(data)) if err != nil { 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{"编码 名称", "1MB", "10000", "整批", "重复", "库存", "第 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("模板缺少「物料数据」工作表") } if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 { t.Fatal("模板缺少「填写说明」工作表") } headers, err := f.GetRows(MaterialImportSheetData) if err != nil { t.Fatalf("read rows: %v", err) } if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" { t.Fatalf("表头 = %v", headers) } // 模板必须能被自己的解析器接受(表头一致) if rows, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil { t.Fatalf("模板无法被解析器接受: %v", err) } else if len(rows) != 0 { t.Fatal("数据页不应包含会被实际导入的示例物料") } } // buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。 func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte { t.Helper() return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows) } // buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。 func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte { t.Helper() f := excelize.NewFile() defer f.Close() f.SetSheetName("Sheet1", MaterialImportSheetData) for i, header := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 1) f.SetCellValue(MaterialImportSheetData, cell, header) } for r, row := range dataRows { for c, value := range row { cell, _ := excelize.CoordinatesToCellName(c+1, r+2) f.SetCellValue(MaterialImportSheetData, cell, value) } } buf, err := f.WriteToBuffer() if err != nil { t.Fatalf("write workbook: %v", err) } return buf.Bytes() }