material_manage_system/internal/service/material_import_excel_test.go
文睿 张 18df4f48e4 修复 21 个测试与代码脱节导致的既有失败
go test ./... 从 21 个失败回到 0。根因都是测试没跟上代码改动,其中两处
被「自动补位」的补丁 hack 掩盖住了:

- handler 的 materialRow 在「列数 20、值 18」时自动插两个 nil,掩盖了
  materials 新增 supplier_id/manufacturer_id 后的列错位。它碰巧补对位置,
  而 order 测试的 activeMaterialRows 没走这个 helper,直接 panic。
  已删掉补丁、把测试数据补齐到 20 列。
- 导入测试的 buildTestWorkbookWithHeaders 按 7 列给数据补位,而表头这时
  已经扩到 9 列(多了供应商、制造商),把「大类/小类」静默塞进了
  「来源/备注」。已按 materialImportHeaders 的实际列序修正补位。

其余为非补丁式的期望过时:materials 的 INSERT 参数补到 15 个、物料列表
查询结果集补到 20 列、导入模板表头断言改为按 materialImportHeaders 取末列
而不是写死下标。

顺带清掉已废弃的升级路径残留:upgrades/ 目录在 85525c97 已删除,增量升级
不再使用,因此删除 scripts/sync-company-integrity.ps1(把 checks SQL 嵌入
upgrade SQL 的生成器)与它的测试,application.go 里指向 upgrades 的错误
文案改为只提 init.sql。
2026-10-14 16:27:18 +08:00

289 lines
9.8 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 (
"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("</sheetData>"),
[]byte(`<row r="20003"><c></row></sheetData>`), 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)
}
// 表头列数以 materialImportHeaders 为准:写死下标会在增删列时静默指向别的列。
if len(headers) == 0 || len(headers[0]) != len(materialImportHeaders) ||
headers[0][0] != "物料名称" || headers[0][len(headers[0])-1] != "小类" {
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()
// 测试数据用五列简写「名称、规格、单位、大类、小类」,其余列补空。
// 补位的顺序必须与 materialImportHeaders 一致(物料名称、规格、单位、供应商、
// 制造商、来源、备注、大类、小类):表头改列时这里要同步改,否则大类小类会被
// 静默解析成来源和备注,测试却只是「结果对不上」。
if strings.Join(headers, "\x00") == strings.Join(materialImportHeaders, "\x00") {
for i, row := range dataRows {
if len(row) == 5 {
dataRows[i] = []string{row[0], row[1], row[2], "", "", "", "", row[3], row[4]}
}
}
}
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()
}