From 0ddda5363e3ba4965f64aa3eaf2965cd7b4acb64 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 11:34:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(material-import):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=BC=82=E5=B8=B8=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E4=BB=85=E4=BF=9D=E5=AD=98=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=90=8D=E7=A7=B0=E3=80=81=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E3=80=81=E8=AF=A6=E6=83=85=E5=8F=8A=E7=89=A9=E6=96=99=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E3=80=82=E4=B8=8A=E4=BC=A0=E8=A7=A3=E6=9E=90=E5=89=8D?= =?UTF-8?q?=E9=99=90=E5=88=B6=E8=AF=B7=E6=B1=82=E5=A4=A7=E5=B0=8F=EF=BC=8C?= =?UTF-8?q?=E9=80=90=E8=A1=8C=E8=AF=BB=E5=8F=96=E6=8F=90=E5=89=8D=E6=8B=A6?= =?UTF-8?q?=E6=88=AA=E8=B6=85=E9=99=90=E6=95=B0=E6=8D=AE=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=A9=BA=E8=A1=8C=E5=92=8C=E7=9C=9F=E5=AE=9E=E8=A1=8C?= =?UTF-8?q?=E5=8F=B7=E8=A7=84=E5=88=99=E3=80=82=E8=A1=A5=E5=85=85=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=90=8C=E6=AD=A5=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E3=80=81=E6=8E=A5=E5=8F=A3=E8=AF=B4=E6=98=8E=E5=8F=8A?= =?UTF-8?q?=E5=85=AB=E6=9D=A1=E4=BA=BA=E5=B7=A5=E7=94=A8=E4=BE=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api.md | 4 +- .../plans/2026-10-10-material-bulk-import.md | 404 ++++++++++++++---- docs/回归测试用例.md | 84 +++- frontend/src/views/Logs.test.js | 45 ++ frontend/src/views/Logs.vue | 4 +- frontend/src/views/Materials.test.js | 57 +++ frontend/src/views/Materials.vue | 3 +- internal/handler/material_import.go | 19 + internal/handler/material_import_test.go | 86 ++++ internal/model/material_import.go | 3 +- internal/repository/material_import.go | 4 +- internal/service/material_import.go | 75 ++-- internal/service/material_import_excel.go | 39 +- .../service/material_import_excel_test.go | 57 +++ internal/service/material_import_test.go | 99 ++++- 15 files changed, 817 insertions(+), 166 deletions(-) create mode 100644 frontend/src/views/Logs.test.js diff --git a/docs/api.md b/docs/api.md index d47501e5..f30fbe08 100644 --- a/docs/api.md +++ b/docs/api.md @@ -418,6 +418,8 @@ **请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 5 MB,数据区不超过 20,000 行(不含表头)。 +服务端在 multipart 解析前限制请求体为 5 MiB + 64 KiB(表单开销),文件本身仍限制为 5 MiB;超大请求在读到上限时提前拒绝。Excel 使用逐行迭代读取,发现第 20,001 条非空数据时立即停止,表头和空行不计数;较大的工作表 XML 使用临时文件,避免将整张表的所有行缓存到内存。 + **逐行校验规则:** | 列 | 规则 | @@ -468,7 +470,7 @@ - **任意一行校验失败,整批不入库**,响应中返回全部逐行错误;重新上传前请先修正后重试。 - 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。 -- 每次导入(无论成功或失败)都会写一条 `material_import_logs` 记录:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要(前 5 行行号)。**逐行错误明细只随响应返回,不落库。** +- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。 - 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。 - **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。 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 97cdc157..9566ec64 100644 --- a/docs/superpowers/plans/2026-10-10-material-bulk-import.md +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -4,7 +4,7 @@ **Goal:** 物料页面提供「下载模板」与「批量导入」两个入口,用户上传填好的 .xlsx 后,后端逐行校验并在同一个请求内事务入库,失败时整批不入库并返回逐行错误明细。 -**Architecture:** 新增 `internal/service/material_import*.go` 负责 xlsx 解析、模板生成与逐行校验;`internal/repository/material.go` 新增批量入库方法(单事务内锁编码规则、分配编码、分批多值插入);`internal/handler/material_import.go` 暴露两个 HTTP 接口。每次导入(无论成败)写一条 `material_import_logs` 记录;逐行错误只在响应里返回,不落库。 +**Architecture:** 新增 `internal/service/material_import*.go` 负责 xlsx 解析、模板生成与逐行校验;`internal/repository/material.go` 新增批量入库方法(单事务内锁编码规则、分配编码、分批多值插入);`internal/handler/material_import.go` 暴露两个 HTTP 接口。仅记录成功导入:事务提交后写一条 `material_import_logs` 成功汇总;失败导入不写历史记录,逐行错误只在响应里返回,不落库。成功物料的 CREATE 日志通过现有日志页「查看详情」展示「批量导入创建」。 **Tech Stack:** Go 1.21、Gin、MySQL 8.0、`github.com/xuri/excelize/v2 v2.8.1`、sqlmock、Vue 3、Pinia、Element Plus、Vitest。 @@ -13,7 +13,7 @@ ## Global Constraints - Excel 库固定 `github.com/xuri/excelize/v2 v2.8.1`:**不得升级到 v2.9+**,v2.9 起要求 Go ≥1.23,而 `Dockerfile` 使用 `golang:1.21-alpine`(已实测 v2.8.1 可编译、可 `OpenReader`/`WriteToBuffer`)。 -- 文件限制:仅 `.xlsx`,≤ 5 MB,数据区 ≤ 20,000 行(不含表头)。 +- 文件限制:仅 `.xlsx`,≤ 5 MiB;在 multipart 解析前使用 `http.MaxBytesReader` 将请求体限制为 5 MiB + 64 KiB(表单开销),解析后仍独立校验文件大小。使用 `Rows` 迭代器逐行读取,最多保留 20,000 条非空数据,发现第 20,001 条立即停止;表头和空行不计数。大于 1 MiB 的工作表/共享字符串 XML 使用临时文件,退出时关闭迭代器、工作簿并清理临时文件。 - 首版仅支持**新增**物料,不做更新、不做分类/单位的自动创建。 - 物料编码由系统按小类编码规则生成,**不接受 Excel 中的编码列**;小类未配置启用的编码规则时该行报错。 - 任意一行校验失败 → 整批不入库,返回全部逐行错误。 @@ -109,7 +109,8 @@ - 整批入库在**一个事务**内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 `material_logs`(每条物料一条 `CREATE` 审计记录,与单条新增保持一致)。 - 任一行插入失败则整体回滚。 -- 每次导入(成功或失败)都写一条 `material_import_logs`:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要。**逐行错误明细不落库**,只在响应里返回。 +- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数;失败数为 0、状态为 `success`、失败原因为空。模板错误、主数据查询失败、行校验失败和事务失败均不写历史记录;**逐行错误明细不落库**,只在响应里返回并可由前端导出 CSV。 +- 成功导入的每件物料保存完整 `new_value` JSON 快照,与单条新增保持一致;现有日志页「查看详情」首行显示「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的编码、名称、规格、分类名称、单位名称和状态,并显示操作人及时间;继续使用该入口查看成功操作。该页面展示物料操作日志,不展示 `material_import_logs` 的文件名或批次汇总。 ### 7. 权限 @@ -135,7 +136,7 @@ | `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 | | `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) | | `internal/service/material_import.go` | 导入编排:解析 → 校验 → 入库 → 写日志 | -| `internal/repository/material_import.go` | `material_import_logs` 的写入与查询 | +| `internal/repository/material_import.go` | `material_import_logs` 成功汇总的写入 | | `internal/handler/material_import.go` | 两个 HTTP handler | | `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 | | `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) | @@ -246,7 +247,8 @@ type MaterialImportResult struct { Errors []MaterialImportError `json:"errors"` } -// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 +// MaterialImportLog 是一次成功导入的持久化汇总;失败不写记录。 +// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。 type MaterialImportLog struct { ID int64 `json:"id"` OrganizationID int64 `json:"organization_id"` @@ -318,13 +320,70 @@ Expected: `github.com/xuri/excelize/v2 v2.8.1`(**不可为 v2.9+**) package service import ( + "archive/zip" "bytes" "errors" + "io" "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", "个", "机械结构类", "标准件"}, @@ -612,8 +671,12 @@ func guideHeaderStyle(f *excelize.File) int { // 或缺少「物料数据」表时才判定模板不合法。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { - f, err := excelize.OpenReader(r) + // 较大的工作表 XML 使用临时文件,避免压缩后很小的工作簿整表驻留内存。 + f, err := excelize.OpenReader(r, excelize.Options{UnzipXMLSizeLimit: 1 << 20}) if err != nil { + if f != nil { + _ = f.Close() + } return nil, ErrImportTemplateInvalid } defer f.Close() @@ -623,24 +686,35 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { return nil, ErrImportTemplateInvalid } - all, err := f.GetRows(MaterialImportSheetData) + iterator, err := f.Rows(MaterialImportSheetData) if err != nil { return nil, err } - if len(all) == 0 { + defer iterator.Close() + if !iterator.Next() { return nil, ErrImportTemplateInvalid } - columns := materialImportColumnIndex(all[0]) + header, err := iterator.Columns() + if err != nil { + return nil, err + } + columns := materialImportColumnIndex(header) 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:] { + rows := make([]model.MaterialImportRow, 0, 256) + rowNumber := 1 + for iterator.Next() { + rowNumber++ + raw, err := iterator.Columns() + if err != nil { + return nil, err + } row := model.MaterialImportRow{ - Row: i + 2, // Excel 行号:表头占第 1 行 + Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。 Name: materialImportColumnValue(raw, columns, "物料名称"), Spec: materialImportColumnValue(raw, columns, "规格"), Unit: materialImportColumnValue(raw, columns, "单位"), @@ -650,11 +724,17 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { continue } + if len(rows) == MaterialImportMaxRows { + return nil, ErrImportTooManyRows + } rows = append(rows, row) } - if len(rows) > MaterialImportMaxRows { - return nil, ErrImportTooManyRows + if err := iterator.Error(); err != nil { + return nil, err + } + if err := iterator.Close(); err != nil { + return nil, err } return rows, nil } @@ -1209,8 +1289,8 @@ type MaterialImportLogRepo struct{} func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} } -// Create 写一条导入记录。成功和失败都会调用;失败原因只存汇总,逐行明细 -// 只随接口响应返回,不落库。 +// Create 写一条成功导入汇总,由服务在事务提交成功后调用。 +// 失败导入不写记录,逐行错误明细只随接口响应返回,不落库。 func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error { result, err := database.DB.Exec( `INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -1261,6 +1341,9 @@ package service import ( "bytes" + "database/sql/driver" + "encoding/json" + "errors" "regexp" "strings" "testing" @@ -1305,8 +1388,7 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) { } } -// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() -// 表示只校验"写了一条记录",不锁死文案。 +// expectImportLog 校验成功导入的汇总记录。 func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) { // 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), // sqlmock 按类型比对,传 Go int 匹配不上。 @@ -1315,12 +1397,45 @@ func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, succe WillReturnResult(sqlmock.NewResult(9, 1)) } +// 记录写入错误会被服务吞掉,单纯不设置 SQL 期望无法证明没有尝试写入。 +// 设一个最后的写入期望,并确认只有它未被消费,且前面的业务期望全部完成。 +func expectNoImportLog(t *testing.T, mock sqlmock.Sqlmock) func() { + t.Helper() + unexpected := mock.ExpectExec(`INSERT INTO material_import_logs`).WillReturnResult(sqlmock.NewResult(9, 1)) + return func() { + t.Helper() + err := mock.ExpectationsWereMet() + if err == nil { + t.Fatal("失败导入不应写入历史记录") + } + want := "there is a remaining expectation which was not matched: " + unexpected.String() + if err.Error() != want { + t.Fatal(err) + } + } +} + +func TestMaterialImportServiceDoesNotRecordMasterDataFailure(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + queryErr := errors.New("主数据查询失败") + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).WillReturnError(queryErr) + defer expectNoImportLog(t, mock)() + data := buildTestWorkbook(t, [][]string{ + {"螺栓", "", "个", "机械结构类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if !errors.Is(err, queryErr) || result != nil { + t.Fatalf("result = %+v, err = %v", result, err) + } +} + func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, true) - expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"", "", "", "机械结构类", "标准件"}, // 名称缺失 @@ -1339,9 +1454,6 @@ func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") { t.Fatalf("second error = %+v", result.Errors[1]) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { @@ -1349,7 +1461,7 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { defer cleanup() expectImportMasterData(mock, false) - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"螺栓", "", "个", "机械结构类", "标准件"}, @@ -1361,16 +1473,13 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { t.Fatalf("result = %+v", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() - expectImportLog(mock, "错的.xlsx", "failed", 0, 0, 0, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx")) if err == nil { @@ -1379,9 +1488,6 @@ func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { if result != nil { t.Fatalf("result = %+v, want nil", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { @@ -1423,7 +1529,7 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { // 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚 mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) mock.ExpectRollback() - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, @@ -1435,13 +1541,38 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { if result != nil { t.Fatalf("result = %+v, want nil", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } // expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 // → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 +type importSnapshot struct { + code string +} + +func (expected importSnapshot) Match(value driver.Value) bool { + text, ok := value.(string) + if !ok { + return false + } + var snapshot struct { + ID int64 `json:"id"` + OrganizationID int64 `json:"organization_id"` + MaterialCode string `json:"material_code"` + Name string `json:"name"` + Spec string `json:"spec"` + CategoryID int64 `json:"category_id"` + BaseUnitID int64 `json:"base_unit_id"` + Unit string `json:"unit"` + Status string `json:"status"` + Version int `json:"version"` + } + return json.Unmarshal([]byte(text), &snapshot) == nil && + snapshot.ID == 31 && snapshot.OrganizationID == 1 && + snapshot.MaterialCode == expected.code && snapshot.Name == "内六角螺栓" && + snapshot.Spec == "M8x30" && snapshot.CategoryID == 12 && snapshot.BaseUnitID == 1 && + snapshot.Unit == "个" && snapshot.Status == "active" && snapshot.Version == 1 +} + func expectImportPersist(mock sqlmock.Sqlmock, code string) { mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)). @@ -1457,7 +1588,11 @@ func expectImportPersist(mock sqlmock.Sqlmock, code string) { mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). WithArgs(int64(1), code). WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code)) - mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) + description := "批量导入创建:内六角螺栓(" + code + ")\n物料编码:" + code + + "\n名称:内六角螺栓\n规格:M8x30\n分类:机械结构类 / 标准件\n单位:个\n状态:启用" + mock.ExpectExec(`INSERT INTO material_logs`). + WithArgs("CREATE", int64(31), "", importSnapshot{code: code}, "tester", description). + WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() } @@ -1524,7 +1659,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). AddRow(1, 1, "个", "个", 0, "active")) // 大类查不到,小类不再定位,也就不会去查编码规则 - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"}, @@ -1538,25 +1674,19 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") { t.Fatalf("result = %+v", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } // 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 // U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成 // 「从浏览器复制的能导入、自己手写的报不存在」。 -// -// 仓库源码里这两种空格写成字面字符;它们在源码里近乎隐形、容易被编辑器或编码转换 -// 悄悄改掉(和 BOM 同理),所以本文档改用 Go 的 \u 转义写——两种写法完全等价。 func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) { cases := []struct { name string major string minor string }{ - {"全角空格 U+3000", "A\u3000机械结构类", "A01\u3000标准件"}, - {"不换行空格 U+00A0", "A\u00A0机械结构类", "A01\u00A0标准件"}, + {"全角空格 U+3000", "A 机械结构类", "A01 标准件"}, + {"不换行空格 U+00A0", "A 机械结构类", "A01 标准件"}, {"连续半角空格", "A 机械结构类", "A01 标准件"}, } for _, tc := range cases { @@ -1599,6 +1729,7 @@ Expected: FAIL / 编译错误 `undefined: NewMaterialImportService` package service import ( + "encoding/json" "fmt" "io" "strings" @@ -1616,6 +1747,13 @@ type MaterialImportService struct { importRepo *repository.MaterialImportLogRepo } +// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。 +type materialImportItem struct { + *model.Material + categoryLabel string + unitLabel string +} + func NewMaterialImportService() *MaterialImportService { return &MaterialImportService{ materialRepo: repository.NewMaterialRepo(), @@ -1630,7 +1768,7 @@ func NewMaterialImportService() *MaterialImportService { // - 模板或文件不合法:返回 nil 与错误,前端提示原因; // - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。 // -// 三种情况都会写一条导入记录。 +// 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。 func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { if organizationID <= 0 { return nil, ErrInvalidOrganizationID @@ -1638,13 +1776,11 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName rows, err := ParseMaterialImportFile(r) if err != nil { - s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error()) return nil, err } materials, importErrors, err := s.validateRows(organizationID, rows) if err != nil { - s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error()) return nil, err } if len(importErrors) > 0 { @@ -1654,13 +1790,10 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName FailedRows: len(importErrors), Errors: importErrors, } - s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows, - model.MaterialImportStatusFailed, summarizeImportErrors(importErrors)) return result, nil } if err := s.persist(organizationID, operator, materials); err != nil { - s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error()) return nil, err } @@ -1670,13 +1803,13 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName FailedRows: 0, Errors: []model.MaterialImportError{}, } - s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "") + s.recordSuccessfulImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows) return result, nil } // validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级 // 错误;逐行的问题以 importErrors 返回,两者互斥。 -func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) { +func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*materialImportItem, []model.MaterialImportError, error) { categories, err := s.masterRepo.ListCategories(organizationID) if err != nil { return nil, nil, err @@ -1726,7 +1859,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. } } - materials := make([]*model.Material, 0, len(rows)) + materials := make([]*materialImportItem, 0, len(rows)) importErrors := make([]model.MaterialImportError, 0) // 同一小类的规则只查一次:两万行通常只落在少数几个小类上, // 逐行去查会把一次导入放大成两万次往返。 @@ -1814,7 +1947,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. baseUnitID = &id } categoryID := minor.ID - materials = append(materials, &model.Material{ + materials = append(materials, &materialImportItem{Material: &model.Material{ OrganizationID: organizationID, Name: name, Spec: row.Spec, @@ -1823,31 +1956,46 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. Unit: unitName, Status: "active", Version: 1, - }) + }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name}) } return materials, importErrors, nil } // persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。 -func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error { +func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error { tx, err := repository.BeginTx() if err != nil { return err } defer func() { _ = tx.Rollback() }() - if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil { + materialRows := make([]*model.Material, 0, len(materials)) + for _, item := range materials { + materialRows = append(materialRows, item.Material) + } + if err := s.materialRepo.ImportTx(tx, organizationID, materialRows); err != nil { return err } logs := make([]*model.MaterialLog, 0, len(materials)) for _, material := range materials { + newValue, err := json.Marshal(material.Material) + if err != nil { + return err + } + spec := material.Spec + if spec == "" { + spec = "—" + } logs = append(logs, &model.MaterialLog{ OperationType: "CREATE", MaterialID: material.ID, + NewValue: string(newValue), Operator: operator, - Description: "批量导入创建", + Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s", + material.Name, material.MaterialCode, material.MaterialCode, material.Name, spec, + material.categoryLabel, material.unitLabel, materialStatusLabel(material.Status)), }) } if err := s.logRepo.CreateBatchTx(tx, logs); err != nil { @@ -1856,40 +2004,21 @@ func (s *MaterialImportService) persist(organizationID int64, operator string, m return tx.Commit() } -// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 +// recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 // 变成失败,所以这里吞掉错误。 -func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) { - if len([]rune(reason)) > 1000 { - reason = string([]rune(reason)[:1000]) - } +func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) { _ = s.importRepo.Create(&model.MaterialImportLog{ OrganizationID: organizationID, Operator: operator, FileName: fileName, TotalRows: total, SuccessRows: success, - FailedRows: failed, - Status: status, - FailureReason: reason, + FailedRows: 0, + Status: model.MaterialImportStatusSuccess, + FailureReason: "", }) } -// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。 -func summarizeImportErrors(importErrors []model.MaterialImportError) string { - rows := make([]string, 0, 5) - for i, item := range importErrors { - if i == 5 { - break - } - rows = append(rows, fmt.Sprintf("%d", item.Row)) - } - summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、")) - if len(importErrors) > 5 { - summary += " 等" - } - return summary + ")" -} - func minorCategoryKey(majorID int64, minorName string) string { return fmt.Sprintf("%d\x00%s", majorID, minorName) } @@ -1976,15 +2105,101 @@ func ErrorWithData(c *gin.Context, httpStatus int, message string, data interfac package handler import ( + "bytes" + "fmt" + "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" + "github.com/wmms/wmms/internal/service" "github.com/wmms/wmms/pkg/response" ) +type countedImportBody struct { + io.Reader + read int64 +} + +func (body *countedImportBody) Read(p []byte) (int, error) { + n, err := body.Reader.Read(p) + body.read += int64(n) + return n, err +} + +func (*countedImportBody) Close() error { return nil } + +func TestMaterialImportRejectsLargeBodyBeforeReadingItAll(t *testing.T) { + gin.SetMode(gin.TestMode) + var payload bytes.Buffer + writer := multipart.NewWriter(&payload) + part, err := writer.CreateFormFile("file", "物料.xlsx") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), 8<<20)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + for _, contentLength := range []int64{int64(payload.Len()), -1} { + t.Run(fmt.Sprintf("content-length=%d", contentLength), func(t *testing.T) { + body := &countedImportBody{Reader: bytes.NewReader(payload.Bytes())} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set("organization_id", int64(1)) + c.Request = httptest.NewRequest(http.MethodPost, "/material-import", nil) + c.Request.Body = body + c.Request.ContentLength = contentLength + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + (&Handler{}).ImportMaterials(c) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "5MB") { + t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String()) + } + if body.read > service.MaterialImportMaxBytes+(64<<10)+1 { + t.Fatalf("超大请求已读取 %d 字节,应在解析前限制读取", body.read) + } + }) + } +} + +func TestMaterialImportFileSizeBoundaryAllowsMultipartOverhead(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, size := range []int{service.MaterialImportMaxBytes, service.MaterialImportMaxBytes + 1} { + t.Run(fmt.Sprintf("file-size=%d", size), func(t *testing.T) { + var payload bytes.Buffer + writer := multipart.NewWriter(&payload) + part, err := writer.CreateFormFile("file", "物料.xlsx") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), size)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set("organization_id", int64(1)) + c.Request = httptest.NewRequest(http.MethodPost, "/material-import", bytes.NewReader(payload.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + (&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c) + want := "模板格式不正确" + if size > service.MaterialImportMaxBytes { + want = "文件超过 5MB 上限" + } + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) { + t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want) + } + }) + } +} + func TestErrorWithDataKeepsPayload(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() @@ -2057,8 +2272,27 @@ func (h *Handler) ImportMaterials(c *gin.Context) { return } + // 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。 + const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10) + if c.Request.ContentLength > maxRequestBytes { + response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes) + defer c.Request.Body.Close() + defer func() { + if c.Request.MultipartForm != nil { + _ = c.Request.MultipartForm.RemoveAll() + } + }() + file, err := c.FormFile("file") if err != nil { + var sizeErr *http.MaxBytesError + if errors.As(err, &sizeErr) { + response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") + return + } response.BadRequest(c, "请选择要导入的 xlsx 文件") return } @@ -2785,7 +3019,7 @@ docker compose down -v && docker compose up -d --build - [ ] **Step 4: 确认导入记录落库** Run: `docker compose exec db mysql -uroot -pwmms1234 wmms -e "SELECT organization_id,operator,file_name,total_rows,success_rows,failed_rows,status,failure_reason,created_at FROM material_import_logs ORDER BY id DESC LIMIT 5"` -Expected: 每次导入(成功与失败)各一条 +Expected: 每次成功导入新增一条 `success` 记录,失败导入不新增记录;日志页可查看成功新增物料的「批量导入创建」详情。 - [ ] **Step 5: 确认回滚确实生效** @@ -2807,7 +3041,7 @@ git commit -m "docs: 补充物料批量导入接口说明" - **不做重复检测**:同一份文件里两行相同物料都会成功入库,同一份文件重复上传也会导入两遍(编码都不同)。用户故事 BAT-001 提到的「疑似重复物料」不在首版范围。 - **没有服务端幂等键**:设计原文的「任务 ID 防重复提交」随预览环节一起取消,改为纯前端约束,见「设计约定 8」。 -- **错误明细不落库**:只随响应返回,前端用 CSV 导出。因此历史导入记录里只有汇总原因,翻不回逐行明细。 +- **仅记录成功导入**:失败导入不保存历史记录;逐行错误只随响应返回,前端用 CSV 导出,关闭当次结果后不能查询历史失败明细。 - **不使用 `material_types`、`material_unit_conversions`**:与现有单条新增保持一致。 - **不引入消息队列**:20000 行同步处理,前端超时沿用 axios 默认 30 秒;若实测慢,后续再谈异步。 -- **`material_import_logs` 只写汇总**:第 8 点的「执行状态」只有 `success` / `failed` 两态,没有中间态。 +- **`material_import_logs` 只写成功汇总**:新记录状态仅为 `success`,没有中间态;保留已有表结构兼容历史数据。成功物料操作沿用现有日志页查看。 diff --git a/docs/回归测试用例.md b/docs/回归测试用例.md index 4909a1a2..172cd3f8 100644 --- a/docs/回归测试用例.md +++ b/docs/回归测试用例.md @@ -1,8 +1,8 @@ # 物料管理系统回归测试用例 -更新时间:2026-10-09 +更新时间:2026-10-12 -本文用于版本发布前的人工回归检查。用例依据当前仓库已实现的入库、出库、库存、物料申请审批、物料主数据和组织隔离功能编写;权限功能暂不纳入本轮回归。测试时请使用隔离的测试环境和测试账号;涉及库存变更的用例应使用专用测试物料,执行前记录库存基线。 +本文用于版本发布前的人工回归检查。用例依据当前仓库已实现的入库、出库、库存、物料申请审批、物料主数据、物料批量导入和组织隔离功能编写;权限功能暂不纳入本轮回归。测试时请使用隔离的测试环境和测试账号;涉及库存变更的用例应使用专用测试物料,执行前记录库存基线。 ## 通用测试准备 @@ -208,15 +208,89 @@ **判断指标:** 两类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商资料显示在供应商页面,客户资料显示在客户页面,页面间无错误混列。 +## 物料批量导入专项回归(TC-20到TC-27) + +### 测试准备 + +- 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。 +- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。 +- 每个文件使用独立的测试物料名称,执行前记录物料列表、规则下一流水号和库存基线。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。 + +### TC-20 下载导入模板 + +**测试内容:** 验证模板下载和填写说明。 + +**操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。 + +**判断指标:** 文件可正常打开,包含「物料数据」和「填写说明」;表头为「物料名称、规格、单位、大类、小类」。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成。 + +### TC-21 合法数据导入及编码生成 + +**测试内容:** 验证物料新增、列表刷新及编码连续性。 + +**前置数据:** 专用小类编码规则前缀为 `RTIMP`、流水号 4 位、下一流水号为 1,且无已占用编码。 + +**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,检查库存和流水。 + +**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;编码分别为 `RTIMP0001`、`RTIMP0002`,日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为 `RTIMP0003`。导入结果刷新后仍可查询,原有库存及库存流水不变。 + +### TC-22 分类写法与选填字段兼容 + +**测试内容:** 验证分类、单位的不同填写方式及空行处理。 + +**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。在文件中插入全空行;另存一份删除规格和单位列、调整剩余列顺序的文件,分别上传。 + +**判断指标:** 合法写法均能导入并匹配正确分类和单位;规格可为空,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。 + +### TC-23 错误数据校验与整批拒绝 + +**测试内容:** 验证字段错误提示以及合法行不会被部分导入。 + +**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则和库存基线。将超长字段改为 200 字且修正其余错误后重传。 + +**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则和库存不变;全部修正后可成功导入,200 字的名称和规格可接受。 + +### TC-24 错误分页、导出与修正重传 + +**测试内容:** 验证错误明细查看、保存和再次上传。 + +**操作步骤:** 上传含 21 条错误行的文件,其中字段包含中文、逗号或双引号;切换错误表第二页,点击「导出错误明细」,用 Excel 打开 CSV。关闭弹窗,在原 `.xlsx` 中修正全部错误,再选择同一个文件上传。 + +**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、原始字段、失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。 + +### TC-25 文件格式、模板及容量限制 + +**测试内容:** 验证无效文件拦截和大小、行数限制。 + +**操作步骤:** 分别尝试上传 `.xls`、`.csv`、超过 5 MiB(5×1024×1024 字节)的文件、损坏的 `.xlsx`、缺少「物料数据」工作表或必填表头的文件,并尝试取消文件选择。再分别上传 20000 行和 20001 行合法非空数据的 `.xlsx`,两份文件均不超过 5 MiB,记录耗时和结果。 + +**判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。20000 行在允许范围内,应完整导入;20001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。 + +### TC-26 重复数据与重复上传 + +**测试内容:** 验证当前重复导入的实际行为。 + +**操作步骤:** 在同一文件中填写两条同名同规格的合法物料并上传;完成后再次选择同一个文件上传,查询物料及编码。 + +**判断指标:** 按当前功能,两次均成功,每次新增两条,合计四条独立物料,编码互不重复;原有物料未被覆盖。当前不做重复检测,重复上传会再次新增。 + +### TC-27 组织隔离与导入期间操作限制 + +**测试内容:** 验证主数据归属和导入期间的操作限制。 + +**操作步骤:** 在组织 A 上传使用 B 专属分类或单位的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。 + +**判断指标:** B 专属分类或单位在 A 被判不存在,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。 + ## 执行结果记录 | 用例编号 | 执行日期 | 执行人 | 结果(通过/失败/阻塞) | 缺陷单号或备注 | -| --- | --- | --- | --- | --- | -| | | | | | +| ---- | ---- | --- | ------------ | ------- | +| | | | | | ## 使用说明 - 每次回归记录软件版本、测试环境、账号角色和执行结果;库存相关用例同时记录物料、组织及测试前后库存。 - 失败时保留单据编号、申请编号、物料编码、操作时间和页面提示,便于定位。 - 涉及修改或删除单据的用例,核对库存余额及库存流水与最终单据一致;库存流水不作为不可修改的审计日志。 -- 当前系统没有独立盘点、调拨、BOM或批量导入业务流程,本清单不将这些未接入功能作为现有功能验收项。 +- 当前系统没有独立盘点、调拨或 BOM 业务流程,本清单不将这些未接入功能作为现有功能验收项;物料批量导入已纳入 TC-20~TC-27(人工测试)。 diff --git a/frontend/src/views/Logs.test.js b/frontend/src/views/Logs.test.js new file mode 100644 index 00000000..47ce24cc --- /dev/null +++ b/frontend/src/views/Logs.test.js @@ -0,0 +1,45 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { describe, expect, test, vi } from 'vitest' +import Logs from './Logs.vue' + +vi.mock('../stores/logs', () => ({ + useLogsStore: () => ({ + logs: [], loading: false, + page: { currentPage: 1, pageSize: 20, total: 0 }, + fetchLogs: vi.fn() + }) +})) +vi.mock('../stores/materials', () => ({ + useMaterialsStore: () => ({ + historicalMaterialOptions: [], + fetchHistoricalMaterialOptions: vi.fn() + }) +})) + +describe('导入日志详情', () => { + test('显示导入时的字段详情并保留换行', async () => { + const description = '批量导入创建:螺栓(RTIMP0001)\n物料编码:RTIMP0001\n名称:螺栓\n规格:M8\n分类:机械结构类 / 标准件\n单位:个\n状态:启用' + const wrapper = mount(Logs, { + global: { + plugins: [ElementPlus], + stubs: { teleport: true, ElSelect: true, ElOption: true, ElDatePicker: true } + } + }) + try { + wrapper.vm.$.setupState.showDetail({ + material_id: 31, operation_type: 'CREATE', operator: 'tester', + created_at: '2026-10-12T10:00:00', description, + old_value: '', new_value: '{"name":"螺栓","material_code":"RTIMP0001"}' + }) + await flushPromises() + + const detail = wrapper.get('.log-description') + expect(detail.text()).toBe(description) + expect(detail.element.style.whiteSpace).toBe('pre-wrap') + expect(wrapper.find('.value-compare').text()).toContain('"material_code": "RTIMP0001"') + } finally { + wrapper.unmount() + } + }) +}) diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue index 17bb6813..40ff4bba 100644 --- a/frontend/src/views/Logs.vue +++ b/frontend/src/views/Logs.vue @@ -72,7 +72,9 @@ {{ currentRow.operator }} {{ formatDate(currentRow.created_at) }} - {{ currentRow.description }} + + {{ currentRow.description }} +

变更对比

diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 0bbad1b2..91a3ceba 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -616,10 +616,18 @@ describe('物料图片上传的保存互斥', () => { describe('批量导入', () => { beforeEach(() => { + materialCategoriesApi.list.mockResolvedValue({ data: categories }) + unitsApi.list.mockResolvedValue({ data: [] }) materialsApi.importFile.mockReset() materialsApi.downloadImportTemplate.mockReset() materialsStore.fetchMaterials.mockReset() vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + vi.spyOn(ElMessage, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() }) test('批量导入成功后提示成功并刷新列表', async () => { @@ -667,7 +675,56 @@ describe('批量导入', () => { await flushPromises() expect(setupState(wrapper).importResult.failed_rows).toBe(1) + expect(ElMessage.error).not.toHaveBeenCalled() expect(materialsStore.fetchMaterials).not.toHaveBeenCalled() wrapper.unmount() }) + + test.each([ + [400, '模板格式不正确,请重新下载最新模板', '模板格式不正确,请重新下载最新模板'], + [400, '文件超过 5MB 上限', '文件超过 5MB 上限'], + [500, '物料编码已存在', '物料编码已存在'], + [500, 'Internal Server Error', '批量导入失败'], + [500, undefined, '批量导入失败'] + ])('HTTP %s 导入错误无逐行明细时提示:%s', async (status, message, expected) => { + const error = new Error(`Request failed with status code ${status}`) + error.response = { status, data: { code: status, message } } + materialsApi.importFile.mockRejectedValue(error) + + const wrapper = mountMaterials() + try { + await flushPromises() + materialsStore.fetchMaterials.mockClear() + + await setupState(wrapper).handleImportFile({ + target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' } + }) + await flushPromises() + + expect(ElMessage.error).toHaveBeenCalledTimes(1) + expect(ElMessage.error).toHaveBeenCalledWith(expected) + expect(setupState(wrapper).importDialogVisible).toBe(false) + expect(setupState(wrapper).importing).toBe(false) + expect(materialsStore.fetchMaterials).not.toHaveBeenCalled() + } finally { + wrapper.unmount() + } + }) + + test('网络中断时仍提示导入失败并解除上传状态', async () => { + materialsApi.importFile.mockRejectedValue(new Error('Network Error')) + const wrapper = mountMaterials() + try { + await flushPromises() + await setupState(wrapper).handleImportFile({ + target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' } + }) + + expect(ElMessage.error).toHaveBeenCalledTimes(1) + expect(ElMessage.error).toHaveBeenCalledWith('批量导入失败') + expect(setupState(wrapper).importing).toBe(false) + } finally { + wrapper.unmount() + } + }) }) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 3d4c6b03..c058c028 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -311,7 +311,8 @@ async function handleImportFile(event) { if (detail && Array.isArray(detail.errors)) { importResult.value = detail importDialogVisible.value = true - } else if (!e?.response) { + } else { + // 导入请求关闭了全局提示,HTTP 错误和网络错误都由页面兜底。 console.error('批量导入失败:', e) ElMessage.error(errorText(e, '批量导入失败')) } diff --git a/internal/handler/material_import.go b/internal/handler/material_import.go index 5f234c18..a42b5119 100644 --- a/internal/handler/material_import.go +++ b/internal/handler/material_import.go @@ -33,8 +33,27 @@ func (h *Handler) ImportMaterials(c *gin.Context) { return } + // 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。 + const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10) + if c.Request.ContentLength > maxRequestBytes { + response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes) + defer c.Request.Body.Close() + defer func() { + if c.Request.MultipartForm != nil { + _ = c.Request.MultipartForm.RemoveAll() + } + }() + file, err := c.FormFile("file") if err != nil { + var sizeErr *http.MaxBytesError + if errors.As(err, &sizeErr) { + response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") + return + } response.BadRequest(c, "请选择要导入的 xlsx 文件") return } diff --git a/internal/handler/material_import_test.go b/internal/handler/material_import_test.go index f832322a..a67f81e8 100644 --- a/internal/handler/material_import_test.go +++ b/internal/handler/material_import_test.go @@ -1,15 +1,101 @@ package handler import ( + "bytes" + "fmt" + "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" + "github.com/wmms/wmms/internal/service" "github.com/wmms/wmms/pkg/response" ) +type countedImportBody struct { + io.Reader + read int64 +} + +func (body *countedImportBody) Read(p []byte) (int, error) { + n, err := body.Reader.Read(p) + body.read += int64(n) + return n, err +} + +func (*countedImportBody) Close() error { return nil } + +func TestMaterialImportRejectsLargeBodyBeforeReadingItAll(t *testing.T) { + gin.SetMode(gin.TestMode) + var payload bytes.Buffer + writer := multipart.NewWriter(&payload) + part, err := writer.CreateFormFile("file", "物料.xlsx") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), 8<<20)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + for _, contentLength := range []int64{int64(payload.Len()), -1} { + t.Run(fmt.Sprintf("content-length=%d", contentLength), func(t *testing.T) { + body := &countedImportBody{Reader: bytes.NewReader(payload.Bytes())} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set("organization_id", int64(1)) + c.Request = httptest.NewRequest(http.MethodPost, "/material-import", nil) + c.Request.Body = body + c.Request.ContentLength = contentLength + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + (&Handler{}).ImportMaterials(c) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "5MB") { + t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String()) + } + if body.read > service.MaterialImportMaxBytes+(64<<10)+1 { + t.Fatalf("超大请求已读取 %d 字节,应在解析前限制读取", body.read) + } + }) + } +} + +func TestMaterialImportFileSizeBoundaryAllowsMultipartOverhead(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, size := range []int{service.MaterialImportMaxBytes, service.MaterialImportMaxBytes + 1} { + t.Run(fmt.Sprintf("file-size=%d", size), func(t *testing.T) { + var payload bytes.Buffer + writer := multipart.NewWriter(&payload) + part, err := writer.CreateFormFile("file", "物料.xlsx") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("x"), size)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set("organization_id", int64(1)) + c.Request = httptest.NewRequest(http.MethodPost, "/material-import", bytes.NewReader(payload.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + (&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c) + want := "模板格式不正确" + if size > service.MaterialImportMaxBytes { + want = "文件超过 5MB 上限" + } + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) { + t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want) + } + }) + } +} + func TestErrorWithDataKeepsPayload(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() diff --git a/internal/model/material_import.go b/internal/model/material_import.go index b1bfee1c..a9bed3d7 100644 --- a/internal/model/material_import.go +++ b/internal/model/material_import.go @@ -40,7 +40,8 @@ type MaterialImportResult struct { Errors []MaterialImportError `json:"errors"` } -// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 +// MaterialImportLog 是一次成功导入的持久化汇总;失败不写记录。 +// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。 type MaterialImportLog struct { ID int64 `json:"id"` OrganizationID int64 `json:"organization_id"` diff --git a/internal/repository/material_import.go b/internal/repository/material_import.go index 5200c853..7016f516 100644 --- a/internal/repository/material_import.go +++ b/internal/repository/material_import.go @@ -9,8 +9,8 @@ type MaterialImportLogRepo struct{} func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} } -// Create 写一条导入记录。成功和失败都会调用;失败原因只存汇总,逐行明细 -// 只随接口响应返回,不落库。 +// Create 写一条成功导入汇总,由服务在事务提交成功后调用。 +// 失败导入不写记录,逐行错误明细只随接口响应返回,不落库。 func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error { result, err := database.DB.Exec( `INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, diff --git a/internal/service/material_import.go b/internal/service/material_import.go index 69cc56be..a4e6ddbf 100644 --- a/internal/service/material_import.go +++ b/internal/service/material_import.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "io" "strings" @@ -18,6 +19,13 @@ type MaterialImportService struct { importRepo *repository.MaterialImportLogRepo } +// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。 +type materialImportItem struct { + *model.Material + categoryLabel string + unitLabel string +} + func NewMaterialImportService() *MaterialImportService { return &MaterialImportService{ materialRepo: repository.NewMaterialRepo(), @@ -32,7 +40,7 @@ func NewMaterialImportService() *MaterialImportService { // - 模板或文件不合法:返回 nil 与错误,前端提示原因; // - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。 // -// 三种情况都会写一条导入记录。 +// 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。 func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { if organizationID <= 0 { return nil, ErrInvalidOrganizationID @@ -40,13 +48,11 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName rows, err := ParseMaterialImportFile(r) if err != nil { - s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error()) return nil, err } materials, importErrors, err := s.validateRows(organizationID, rows) if err != nil { - s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error()) return nil, err } if len(importErrors) > 0 { @@ -56,13 +62,10 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName FailedRows: len(importErrors), Errors: importErrors, } - s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows, - model.MaterialImportStatusFailed, summarizeImportErrors(importErrors)) return result, nil } if err := s.persist(organizationID, operator, materials); err != nil { - s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error()) return nil, err } @@ -72,13 +75,13 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName FailedRows: 0, Errors: []model.MaterialImportError{}, } - s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "") + s.recordSuccessfulImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows) return result, nil } // validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级 // 错误;逐行的问题以 importErrors 返回,两者互斥。 -func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) { +func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*materialImportItem, []model.MaterialImportError, error) { categories, err := s.masterRepo.ListCategories(organizationID) if err != nil { return nil, nil, err @@ -128,7 +131,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. } } - materials := make([]*model.Material, 0, len(rows)) + materials := make([]*materialImportItem, 0, len(rows)) importErrors := make([]model.MaterialImportError, 0) // 同一小类的规则只查一次:两万行通常只落在少数几个小类上, // 逐行去查会把一次导入放大成两万次往返。 @@ -216,7 +219,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. baseUnitID = &id } categoryID := minor.ID - materials = append(materials, &model.Material{ + materials = append(materials, &materialImportItem{Material: &model.Material{ OrganizationID: organizationID, Name: name, Spec: row.Spec, @@ -225,31 +228,46 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model. Unit: unitName, Status: "active", Version: 1, - }) + }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name}) } return materials, importErrors, nil } // persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。 -func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error { +func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error { tx, err := repository.BeginTx() if err != nil { return err } defer func() { _ = tx.Rollback() }() - if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil { + materialRows := make([]*model.Material, 0, len(materials)) + for _, item := range materials { + materialRows = append(materialRows, item.Material) + } + if err := s.materialRepo.ImportTx(tx, organizationID, materialRows); err != nil { return err } logs := make([]*model.MaterialLog, 0, len(materials)) for _, material := range materials { + newValue, err := json.Marshal(material.Material) + if err != nil { + return err + } + spec := material.Spec + if spec == "" { + spec = "—" + } logs = append(logs, &model.MaterialLog{ OperationType: "CREATE", MaterialID: material.ID, + NewValue: string(newValue), Operator: operator, - Description: "批量导入创建", + Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s", + material.Name, material.MaterialCode, material.MaterialCode, material.Name, spec, + material.categoryLabel, material.unitLabel, materialStatusLabel(material.Status)), }) } if err := s.logRepo.CreateBatchTx(tx, logs); err != nil { @@ -258,40 +276,21 @@ func (s *MaterialImportService) persist(organizationID int64, operator string, m return tx.Commit() } -// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 +// recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 // 变成失败,所以这里吞掉错误。 -func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) { - if len([]rune(reason)) > 1000 { - reason = string([]rune(reason)[:1000]) - } +func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) { _ = s.importRepo.Create(&model.MaterialImportLog{ OrganizationID: organizationID, Operator: operator, FileName: fileName, TotalRows: total, SuccessRows: success, - FailedRows: failed, - Status: status, - FailureReason: reason, + FailedRows: 0, + Status: model.MaterialImportStatusSuccess, + FailureReason: "", }) } -// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。 -func summarizeImportErrors(importErrors []model.MaterialImportError) string { - rows := make([]string, 0, 5) - for i, item := range importErrors { - if i == 5 { - break - } - rows = append(rows, fmt.Sprintf("%d", item.Row)) - } - summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、")) - if len(importErrors) > 5 { - summary += " 等" - } - return summary + ")" -} - func minorCategoryKey(majorID int64, minorName string) string { return fmt.Sprintf("%d\x00%s", majorID, minorName) } diff --git a/internal/service/material_import_excel.go b/internal/service/material_import_excel.go index 53c8fe7d..4dea6597 100644 --- a/internal/service/material_import_excel.go +++ b/internal/service/material_import_excel.go @@ -116,8 +116,12 @@ func guideHeaderStyle(f *excelize.File) int { // 或缺少「物料数据」表时才判定模板不合法。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { - f, err := excelize.OpenReader(r) + // 较大的工作表 XML 使用临时文件,避免压缩后很小的工作簿整表驻留内存。 + f, err := excelize.OpenReader(r, excelize.Options{UnzipXMLSizeLimit: 1 << 20}) if err != nil { + if f != nil { + _ = f.Close() + } return nil, ErrImportTemplateInvalid } defer f.Close() @@ -127,24 +131,35 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { return nil, ErrImportTemplateInvalid } - all, err := f.GetRows(MaterialImportSheetData) + iterator, err := f.Rows(MaterialImportSheetData) if err != nil { return nil, err } - if len(all) == 0 { + defer iterator.Close() + if !iterator.Next() { return nil, ErrImportTemplateInvalid } - columns := materialImportColumnIndex(all[0]) + header, err := iterator.Columns() + if err != nil { + return nil, err + } + columns := materialImportColumnIndex(header) 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:] { + rows := make([]model.MaterialImportRow, 0, 256) + rowNumber := 1 + for iterator.Next() { + rowNumber++ + raw, err := iterator.Columns() + if err != nil { + return nil, err + } row := model.MaterialImportRow{ - Row: i + 2, // Excel 行号:表头占第 1 行 + Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。 Name: materialImportColumnValue(raw, columns, "物料名称"), Spec: materialImportColumnValue(raw, columns, "规格"), Unit: materialImportColumnValue(raw, columns, "单位"), @@ -154,11 +169,17 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { continue } + if len(rows) == MaterialImportMaxRows { + return nil, ErrImportTooManyRows + } rows = append(rows, row) } - if len(rows) > MaterialImportMaxRows { - return nil, ErrImportTooManyRows + if err := iterator.Error(); err != nil { + return nil, err + } + if err := iterator.Close(); err != nil { + return nil, err } return rows, nil } diff --git a/internal/service/material_import_excel_test.go b/internal/service/material_import_excel_test.go index 3335b62f..7465db7a 100644 --- a/internal/service/material_import_excel_test.go +++ b/internal/service/material_import_excel_test.go @@ -1,13 +1,70 @@ package service import ( + "archive/zip" "bytes" "errors" + "io" "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", "个", "机械结构类", "标准件"}, diff --git a/internal/service/material_import_test.go b/internal/service/material_import_test.go index 67fa8cd1..24765725 100644 --- a/internal/service/material_import_test.go +++ b/internal/service/material_import_test.go @@ -2,6 +2,9 @@ package service import ( "bytes" + "database/sql/driver" + "encoding/json" + "errors" "regexp" "strings" "testing" @@ -46,8 +49,7 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) { } } -// reason 用 interface{}:成功场景断言具体空串,失败场景传 sqlmock.AnyArg() -// 表示只校验"写了一条记录",不锁死文案。 +// expectImportLog 校验成功导入的汇总记录。 func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) { // 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), // sqlmock 按类型比对,传 Go int 匹配不上。 @@ -56,12 +58,45 @@ func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, succe WillReturnResult(sqlmock.NewResult(9, 1)) } +// 记录写入错误会被服务吞掉,单纯不设置 SQL 期望无法证明没有尝试写入。 +// 设一个最后的写入期望,并确认只有它未被消费,且前面的业务期望全部完成。 +func expectNoImportLog(t *testing.T, mock sqlmock.Sqlmock) func() { + t.Helper() + unexpected := mock.ExpectExec(`INSERT INTO material_import_logs`).WillReturnResult(sqlmock.NewResult(9, 1)) + return func() { + t.Helper() + err := mock.ExpectationsWereMet() + if err == nil { + t.Fatal("失败导入不应写入历史记录") + } + want := "there is a remaining expectation which was not matched: " + unexpected.String() + if err.Error() != want { + t.Fatal(err) + } + } +} + +func TestMaterialImportServiceDoesNotRecordMasterDataFailure(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + queryErr := errors.New("主数据查询失败") + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).WillReturnError(queryErr) + defer expectNoImportLog(t, mock)() + data := buildTestWorkbook(t, [][]string{ + {"螺栓", "", "个", "机械结构类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if !errors.Is(err, queryErr) || result != nil { + t.Fatalf("result = %+v, err = %v", result, err) + } +} + func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() expectImportMasterData(mock, true) - expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"", "", "", "机械结构类", "标准件"}, // 名称缺失 @@ -80,9 +115,6 @@ func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") { t.Fatalf("second error = %+v", result.Errors[1]) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { @@ -90,7 +122,7 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { defer cleanup() expectImportMasterData(mock, false) - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"螺栓", "", "个", "机械结构类", "标准件"}, @@ -102,16 +134,13 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { t.Fatalf("result = %+v", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { mock, cleanup := newImportServiceMock(t) defer cleanup() - expectImportLog(mock, "错的.xlsx", "failed", 0, 0, 0, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx")) if err == nil { @@ -120,9 +149,6 @@ func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { if result != nil { t.Fatalf("result = %+v, want nil", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { @@ -164,7 +190,7 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { // 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚 mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) mock.ExpectRollback() - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, @@ -176,13 +202,38 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { if result != nil { t.Fatalf("result = %+v, want nil", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } // expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 // → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 +type importSnapshot struct { + code string +} + +func (expected importSnapshot) Match(value driver.Value) bool { + text, ok := value.(string) + if !ok { + return false + } + var snapshot struct { + ID int64 `json:"id"` + OrganizationID int64 `json:"organization_id"` + MaterialCode string `json:"material_code"` + Name string `json:"name"` + Spec string `json:"spec"` + CategoryID int64 `json:"category_id"` + BaseUnitID int64 `json:"base_unit_id"` + Unit string `json:"unit"` + Status string `json:"status"` + Version int `json:"version"` + } + return json.Unmarshal([]byte(text), &snapshot) == nil && + snapshot.ID == 31 && snapshot.OrganizationID == 1 && + snapshot.MaterialCode == expected.code && snapshot.Name == "内六角螺栓" && + snapshot.Spec == "M8x30" && snapshot.CategoryID == 12 && snapshot.BaseUnitID == 1 && + snapshot.Unit == "个" && snapshot.Status == "active" && snapshot.Version == 1 +} + func expectImportPersist(mock sqlmock.Sqlmock, code string) { mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)). @@ -198,7 +249,11 @@ func expectImportPersist(mock sqlmock.Sqlmock, code string) { mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). WithArgs(int64(1), code). WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code)) - mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) + description := "批量导入创建:内六角螺栓(" + code + ")\n物料编码:" + code + + "\n名称:内六角螺栓\n规格:M8x30\n分类:机械结构类 / 标准件\n单位:个\n状态:启用" + mock.ExpectExec(`INSERT INTO material_logs`). + WithArgs("CREATE", int64(31), "", importSnapshot{code: code}, "tester", description). + WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() } @@ -265,7 +320,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). AddRow(1, 1, "个", "个", 0, "active")) // 大类查不到,小类不再定位,也就不会去查编码规则 - expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) + + defer expectNoImportLog(t, mock)() data := buildTestWorkbook(t, [][]string{ {"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"}, @@ -279,9 +335,6 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") { t.Fatalf("result = %+v", result) } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } } // 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是