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)
- }
}
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是