fix(material-import): 完善批量导入异常提示,仅保存成功历史,补齐审计日志名称、编码、详情及物料快照。上传解析前限制请求大小,逐行读取提前拦截超限数据,保留空行和真实行号规则。补充回归测试,同步计划、接口说明及八条人工用例。

This commit is contained in:
文睿 张 2026-10-12 11:34:39 +08:00
parent 2e526be3fb
commit 0ddda5363e
15 changed files with 817 additions and 166 deletions

View File

@ -418,6 +418,8 @@
**请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 5 MB,数据区不超过 20,000 行(不含表头)。 **请求:** `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 行)→ 写审计日志;任一步失败整体回滚。 - 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
- 每次导入(无论成功或失败)都会写一条 `material_import_logs` 记录:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要(前 5 行行号)。**逐行错误明细只随响应返回,不落库。** - **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。 - 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。 - **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。

View File

@ -4,7 +4,7 @@
**Goal:** 物料页面提供「下载模板」与「批量导入」两个入口,用户上传填好的 .xlsx 后,后端逐行校验并在同一个请求内事务入库,失败时整批不入库并返回逐行错误明细。 **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。 **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 ## 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`)。 - 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 中的编码列**;小类未配置启用的编码规则时该行报错。 - 物料编码由系统按小类编码规则生成,**不接受 Excel 中的编码列**;小类未配置启用的编码规则时该行报错。
- 任意一行校验失败 → 整批不入库,返回全部逐行错误。 - 任意一行校验失败 → 整批不入库,返回全部逐行错误。
@ -109,7 +109,8 @@
- 整批入库在**一个事务**内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 `material_logs`(每条物料一条 `CREATE` 审计记录,与单条新增保持一致)。 - 整批入库在**一个事务**内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 `material_logs`(每条物料一条 `CREATE` 审计记录,与单条新增保持一致)。
- 任一行插入失败则整体回滚。 - 任一行插入失败则整体回滚。
- 每次导入(成功或失败)都写一条 `material_import_logs`:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要。**逐行错误明细不落库**,只在响应里返回。 - **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数;失败数为 0、状态为 `success`、失败原因为空。模板错误、主数据查询失败、行校验失败和事务失败均不写历史记录;**逐行错误明细不落库**,只在响应里返回并可由前端导出 CSV。
- 成功导入的每件物料保存完整 `new_value` JSON 快照,与单条新增保持一致;现有日志页「查看详情」首行显示「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的编码、名称、规格、分类名称、单位名称和状态,并显示操作人及时间;继续使用该入口查看成功操作。该页面展示物料操作日志,不展示 `material_import_logs` 的文件名或批次汇总。
### 7. 权限 ### 7. 权限
@ -135,7 +136,7 @@
| `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 | | `internal/model/material_import.go` | 导入相关模型:行、错误、结果、日志 |
| `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) | | `internal/service/material_import_excel.go` | xlsx 模板生成与解析(纯 IO,不碰数据库) |
| `internal/service/material_import.go` | 导入编排:解析 → 校验 → 入库 → 写日志 | | `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 | | `internal/handler/material_import.go` | 两个 HTTP handler |
| `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 | | `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 |
| `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) | | `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) |
@ -246,7 +247,8 @@ type MaterialImportResult struct {
Errors []MaterialImportError `json:"errors"` Errors []MaterialImportError `json:"errors"`
} }
// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 // MaterialImportLog 是一次成功导入的持久化汇总;失败不写记录。
// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。
type MaterialImportLog struct { type MaterialImportLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"` OrganizationID int64 `json:"organization_id"`
@ -318,13 +320,70 @@ Expected: `github.com/xuri/excelize/v2 v2.8.1`(**不可为 v2.9+**)
package service package service
import ( import (
"archive/zip"
"bytes" "bytes"
"errors" "errors"
"io"
"testing" "testing"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
) )
func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows+1)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
data := buildTestWorkbook(t, dataRows)
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal(err)
}
var changed bytes.Buffer
writer := zip.NewWriter(&changed)
for _, entry := range reader.File {
source, err := entry.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(source)
source.Close()
if err != nil {
t.Fatal(err)
}
if entry.Name == "xl/worksheets/sheet1.xml" {
// 超限行之后故意放非法 XML,证明解析器不会再读取后续行。
content = bytes.Replace(content, []byte("</sheetData>"),
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
}
target, err := writer.Create(entry.Name)
if err != nil {
t.Fatal(err)
}
if _, err := target.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
}
}
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
if err != nil || len(rows) != MaterialImportMaxRows {
t.Fatalf("rows=%d, err=%v", len(rows), err)
}
}
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) { func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
@ -612,8 +671,12 @@ func guideHeaderStyle(f *excelize.File) int {
// 或缺少「物料数据」表时才判定模板不合法。 // 或缺少「物料数据」表时才判定模板不合法。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { 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 err != nil {
if f != nil {
_ = f.Close()
}
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
defer f.Close() defer f.Close()
@ -623,24 +686,35 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
all, err := f.GetRows(MaterialImportSheetData) iterator, err := f.Rows(MaterialImportSheetData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(all) == 0 { defer iterator.Close()
if !iterator.Next() {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
columns := materialImportColumnIndex(all[0]) header, err := iterator.Columns()
if err != nil {
return nil, err
}
columns := materialImportColumnIndex(header)
for _, required := range materialImportRequiredHeaders { for _, required := range materialImportRequiredHeaders {
if _, ok := columns[required]; !ok { if _, ok := columns[required]; !ok {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
} }
rows := make([]model.MaterialImportRow, 0, len(all)-1) rows := make([]model.MaterialImportRow, 0, 256)
for i, raw := range all[1:] { rowNumber := 1
for iterator.Next() {
rowNumber++
raw, err := iterator.Columns()
if err != nil {
return nil, err
}
row := model.MaterialImportRow{ row := model.MaterialImportRow{
Row: i + 2, // Excel 行号:表头占第 1 行 Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
Name: materialImportColumnValue(raw, columns, "物料名称"), Name: materialImportColumnValue(raw, columns, "物料名称"),
Spec: materialImportColumnValue(raw, columns, "规格"), Spec: materialImportColumnValue(raw, columns, "规格"),
Unit: 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 == "" { if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
continue continue
} }
if len(rows) == MaterialImportMaxRows {
return nil, ErrImportTooManyRows
}
rows = append(rows, row) rows = append(rows, row)
} }
if len(rows) > MaterialImportMaxRows { if err := iterator.Error(); err != nil {
return nil, ErrImportTooManyRows return nil, err
}
if err := iterator.Close(); err != nil {
return nil, err
} }
return rows, nil return rows, nil
} }
@ -1209,8 +1289,8 @@ type MaterialImportLogRepo struct{}
func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} } func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} }
// Create 写一条导入记录。成功和失败都会调用;失败原因只存汇总,逐行明细 // Create 写一条成功导入汇总,由服务在事务提交成功后调用。
// 只随接口响应返回,不落库。 // 失败导入不写记录,逐行错误明细只随接口响应返回,不落库。
func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error { func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error {
result, err := database.DB.Exec( 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 (?, ?, ?, ?, ?, ?, ?, ?)`, `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 ( import (
"bytes" "bytes"
"database/sql/driver"
"encoding/json"
"errors"
"regexp" "regexp"
"strings" "strings"
"testing" "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{}) { func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), // 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
// sqlmock 按类型比对,传 Go int 匹配不上。 // sqlmock 按类型比对,传 Go int 匹配不上。
@ -1315,12 +1397,45 @@ func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, succe
WillReturnResult(sqlmock.NewResult(9, 1)) 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) { func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
mock, cleanup := newImportServiceMock(t) mock, cleanup := newImportServiceMock(t)
defer cleanup() defer cleanup()
expectImportMasterData(mock, true) expectImportMasterData(mock, true)
expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"", "", "", "机械结构类", "标准件"}, // 名称缺失 {"", "", "", "机械结构类", "标准件"}, // 名称缺失
@ -1339,9 +1454,6 @@ func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing
if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") { if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") {
t.Fatalf("second error = %+v", result.Errors[1]) t.Fatalf("second error = %+v", result.Errors[1])
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
@ -1349,7 +1461,7 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
defer cleanup() defer cleanup()
expectImportMasterData(mock, false) expectImportMasterData(mock, false)
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"螺栓", "", "个", "机械结构类", "标准件"}, {"螺栓", "", "个", "机械结构类", "标准件"},
@ -1361,16 +1473,13 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") {
t.Fatalf("result = %+v", result) t.Fatalf("result = %+v", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
mock, cleanup := newImportServiceMock(t) mock, cleanup := newImportServiceMock(t)
defer cleanup() 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")) result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx"))
if err == nil { if err == nil {
@ -1379,9 +1488,6 @@ func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
if result != nil { if result != nil {
t.Fatalf("result = %+v, want nil", result) t.Fatalf("result = %+v, want nil", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
@ -1423,7 +1529,7 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
// 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚 // 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}))
mock.ExpectRollback() mock.ExpectRollback()
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
@ -1435,13 +1541,38 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
if result != nil { if result != nil {
t.Fatalf("result = %+v, want nil", result) t.Fatalf("result = %+v, want nil", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 // expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号
// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 // → 插入 → 回查 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) { func expectImportPersist(mock sqlmock.Sqlmock, code string) {
mock.ExpectBegin() 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`)). 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 \(\?\)`). mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
WithArgs(int64(1), code). WithArgs(int64(1), code).
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, 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() mock.ExpectCommit()
} }
@ -1524,7 +1659,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active")) AddRow(1, 1, "个", "个", 0, "active"))
// 大类查不到,小类不再定位,也就不会去查编码规则 // 大类查不到,小类不再定位,也就不会去查编码规则
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"}, {"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"},
@ -1538,25 +1674,19 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") {
t.Fatalf("result = %+v", result) t.Fatalf("result = %+v", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 // 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是
// U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成 // U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成
// 「从浏览器复制的能导入、自己手写的报不存在」。 // 「从浏览器复制的能导入、自己手写的报不存在」。
//
// 仓库源码里这两种空格写成字面字符;它们在源码里近乎隐形、容易被编辑器或编码转换
// 悄悄改掉(和 BOM 同理),所以本文档改用 Go 的 \u 转义写——两种写法完全等价。
func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) { func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
major string major string
minor string minor string
}{ }{
{"全角空格 U+3000", "A\u3000机械结构类", "A01\u3000标准件"}, {"全角空格 U+3000", "A 机械结构类", "A01 标准件"},
{"不换行空格 U+00A0", "A\u00A0机械结构类", "A01\u00A0标准件"}, {"不换行空格 U+00A0", "A 机械结构类", "A01 标准件"},
{"连续半角空格", "A 机械结构类", "A01 标准件"}, {"连续半角空格", "A 机械结构类", "A01 标准件"},
} }
for _, tc := range cases { for _, tc := range cases {
@ -1599,6 +1729,7 @@ Expected: FAIL / 编译错误 `undefined: NewMaterialImportService`
package service package service
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@ -1616,6 +1747,13 @@ type MaterialImportService struct {
importRepo *repository.MaterialImportLogRepo importRepo *repository.MaterialImportLogRepo
} }
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
type materialImportItem struct {
*model.Material
categoryLabel string
unitLabel string
}
func NewMaterialImportService() *MaterialImportService { func NewMaterialImportService() *MaterialImportService {
return &MaterialImportService{ return &MaterialImportService{
materialRepo: repository.NewMaterialRepo(), materialRepo: repository.NewMaterialRepo(),
@ -1630,7 +1768,7 @@ func NewMaterialImportService() *MaterialImportService {
// - 模板或文件不合法:返回 nil 与错误,前端提示原因; // - 模板或文件不合法:返回 nil 与错误,前端提示原因;
// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。 // - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
// //
// 三种情况都会写一条导入记录。 // 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
if organizationID <= 0 { if organizationID <= 0 {
return nil, ErrInvalidOrganizationID return nil, ErrInvalidOrganizationID
@ -1638,13 +1776,11 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
rows, err := ParseMaterialImportFile(r) rows, err := ParseMaterialImportFile(r)
if err != nil { if err != nil {
s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error())
return nil, err return nil, err
} }
materials, importErrors, err := s.validateRows(organizationID, rows) materials, importErrors, err := s.validateRows(organizationID, rows)
if err != nil { if err != nil {
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
return nil, err return nil, err
} }
if len(importErrors) > 0 { if len(importErrors) > 0 {
@ -1654,13 +1790,10 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
FailedRows: len(importErrors), FailedRows: len(importErrors),
Errors: importErrors, Errors: importErrors,
} }
s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows,
model.MaterialImportStatusFailed, summarizeImportErrors(importErrors))
return result, nil return result, nil
} }
if err := s.persist(organizationID, operator, materials); err != 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 return nil, err
} }
@ -1670,13 +1803,13 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
FailedRows: 0, FailedRows: 0,
Errors: []model.MaterialImportError{}, 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 return result, nil
} }
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级 // validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
// 错误;逐行的问题以 importErrors 返回,两者互斥。 // 错误;逐行的问题以 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) categories, err := s.masterRepo.ListCategories(organizationID)
if err != nil { if err != nil {
return nil, nil, err 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) importErrors := make([]model.MaterialImportError, 0)
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上, // 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
// 逐行去查会把一次导入放大成两万次往返。 // 逐行去查会把一次导入放大成两万次往返。
@ -1814,7 +1947,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
baseUnitID = &id baseUnitID = &id
} }
categoryID := minor.ID categoryID := minor.ID
materials = append(materials, &model.Material{ materials = append(materials, &materialImportItem{Material: &model.Material{
OrganizationID: organizationID, OrganizationID: organizationID,
Name: name, Name: name,
Spec: row.Spec, Spec: row.Spec,
@ -1823,31 +1956,46 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
Unit: unitName, Unit: unitName,
Status: "active", Status: "active",
Version: 1, Version: 1,
}) }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
} }
return materials, importErrors, nil return materials, importErrors, nil
} }
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。 // 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() tx, err := repository.BeginTx()
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = tx.Rollback() }() 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 return err
} }
logs := make([]*model.MaterialLog, 0, len(materials)) logs := make([]*model.MaterialLog, 0, len(materials))
for _, material := range 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{ logs = append(logs, &model.MaterialLog{
OperationType: "CREATE", OperationType: "CREATE",
MaterialID: material.ID, MaterialID: material.ID,
NewValue: string(newValue),
Operator: operator, 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 { 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() return tx.Commit()
} }
// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 // recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
// 变成失败,所以这里吞掉错误。 // 变成失败,所以这里吞掉错误。
func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) { func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) {
if len([]rune(reason)) > 1000 {
reason = string([]rune(reason)[:1000])
}
_ = s.importRepo.Create(&model.MaterialImportLog{ _ = s.importRepo.Create(&model.MaterialImportLog{
OrganizationID: organizationID, OrganizationID: organizationID,
Operator: operator, Operator: operator,
FileName: fileName, FileName: fileName,
TotalRows: total, TotalRows: total,
SuccessRows: success, SuccessRows: success,
FailedRows: failed, FailedRows: 0,
Status: status, Status: model.MaterialImportStatusSuccess,
FailureReason: reason, 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 { func minorCategoryKey(majorID int64, minorName string) string {
return fmt.Sprintf("%d\x00%s", majorID, minorName) 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 package handler
import ( import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response" "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) { func TestErrorWithDataKeepsPayload(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@ -2057,8 +2272,27 @@ func (h *Handler) ImportMaterials(c *gin.Context) {
return 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") file, err := c.FormFile("file")
if err != nil { if err != nil {
var sizeErr *http.MaxBytesError
if errors.As(err, &sizeErr) {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大")
return
}
response.BadRequest(c, "请选择要导入的 xlsx 文件") response.BadRequest(c, "请选择要导入的 xlsx 文件")
return return
} }
@ -2785,7 +3019,7 @@ docker compose down -v && docker compose up -d --build
- [ ] **Step 4: 确认导入记录落库** - [ ] **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"` 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: 确认回滚确实生效** - [ ] **Step 5: 确认回滚确实生效**
@ -2807,7 +3041,7 @@ git commit -m "docs: 补充物料批量导入接口说明"
- **不做重复检测**:同一份文件里两行相同物料都会成功入库,同一份文件重复上传也会导入两遍(编码都不同)。用户故事 BAT-001 提到的「疑似重复物料」不在首版范围。 - **不做重复检测**:同一份文件里两行相同物料都会成功入库,同一份文件重复上传也会导入两遍(编码都不同)。用户故事 BAT-001 提到的「疑似重复物料」不在首版范围。
- **没有服务端幂等键**:设计原文的「任务 ID 防重复提交」随预览环节一起取消,改为纯前端约束,见「设计约定 8」。 - **没有服务端幂等键**:设计原文的「任务 ID 防重复提交」随预览环节一起取消,改为纯前端约束,见「设计约定 8」。
- **错误明细不落库**:只随响应返回,前端用 CSV 导出。因此历史导入记录里只有汇总原因,翻不回逐行明细。 - **仅记录成功导入**:失败导入不保存历史记录;逐行错误只随响应返回,前端用 CSV 导出,关闭当次结果后不能查询历史失败明细。
- **不使用 `material_types`、`material_unit_conversions`**:与现有单条新增保持一致。 - **不使用 `material_types`、`material_unit_conversions`**:与现有单条新增保持一致。
- **不引入消息队列**:20000 行同步处理,前端超时沿用 axios 默认 30 秒;若实测慢,后续再谈异步。 - **不引入消息队列**:20000 行同步处理,前端超时沿用 axios 默认 30 秒;若实测慢,后续再谈异步。
- **`material_import_logs` 只写汇总**:第 8 点的「执行状态」只有 `success` / `failed` 两态,没有中间态。 - **`material_import_logs` 只写成功汇总**:新记录状态仅为 `success`,没有中间态;保留已有表结构兼容历史数据。成功物料操作沿用现有日志页查看。

View File

@ -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(人工测试)。

View File

@ -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()
}
})
})

View File

@ -72,7 +72,9 @@
<el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item> <el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item> <el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item>
<el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item> <el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item>
<el-descriptions-item label="详情" :span="2">{{ currentRow.description }}</el-descriptions-item> <el-descriptions-item label="详情" :span="2">
<span class="log-description" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ currentRow.description }}</span>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare"> <div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare">
<h4>变更对比</h4> <h4>变更对比</h4>

View File

@ -616,10 +616,18 @@ describe('物料图片上传的保存互斥', () => {
describe('批量导入', () => { describe('批量导入', () => {
beforeEach(() => { beforeEach(() => {
materialCategoriesApi.list.mockResolvedValue({ data: categories })
unitsApi.list.mockResolvedValue({ data: [] })
materialsApi.importFile.mockReset() materialsApi.importFile.mockReset()
materialsApi.downloadImportTemplate.mockReset() materialsApi.downloadImportTemplate.mockReset()
materialsStore.fetchMaterials.mockReset() materialsStore.fetchMaterials.mockReset()
vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
}) })
test('批量导入成功后提示成功并刷新列表', async () => { test('批量导入成功后提示成功并刷新列表', async () => {
@ -667,7 +675,56 @@ describe('批量导入', () => {
await flushPromises() await flushPromises()
expect(setupState(wrapper).importResult.failed_rows).toBe(1) expect(setupState(wrapper).importResult.failed_rows).toBe(1)
expect(ElMessage.error).not.toHaveBeenCalled()
expect(materialsStore.fetchMaterials).not.toHaveBeenCalled() expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
wrapper.unmount() 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()
}
})
}) })

View File

@ -311,7 +311,8 @@ async function handleImportFile(event) {
if (detail && Array.isArray(detail.errors)) { if (detail && Array.isArray(detail.errors)) {
importResult.value = detail importResult.value = detail
importDialogVisible.value = true importDialogVisible.value = true
} else if (!e?.response) { } else {
// 导入请求关闭了全局提示,HTTP 错误和网络错误都由页面兜底。
console.error('批量导入失败:', e) console.error('批量导入失败:', e)
ElMessage.error(errorText(e, '批量导入失败')) ElMessage.error(errorText(e, '批量导入失败'))
} }

View File

@ -33,8 +33,27 @@ func (h *Handler) ImportMaterials(c *gin.Context) {
return 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") file, err := c.FormFile("file")
if err != nil { if err != nil {
var sizeErr *http.MaxBytesError
if errors.As(err, &sizeErr) {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大")
return
}
response.BadRequest(c, "请选择要导入的 xlsx 文件") response.BadRequest(c, "请选择要导入的 xlsx 文件")
return return
} }

View File

@ -1,15 +1,101 @@
package handler package handler
import ( import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/wmms/wmms/internal/service"
"github.com/wmms/wmms/pkg/response" "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) { func TestErrorWithDataKeepsPayload(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()

View File

@ -40,7 +40,8 @@ type MaterialImportResult struct {
Errors []MaterialImportError `json:"errors"` Errors []MaterialImportError `json:"errors"`
} }
// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 // MaterialImportLog 是一次成功导入的持久化汇总;失败不写记录。
// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。
type MaterialImportLog struct { type MaterialImportLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
OrganizationID int64 `json:"organization_id"` OrganizationID int64 `json:"organization_id"`

View File

@ -9,8 +9,8 @@ type MaterialImportLogRepo struct{}
func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} } func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} }
// Create 写一条导入记录。成功和失败都会调用;失败原因只存汇总,逐行明细 // Create 写一条成功导入汇总,由服务在事务提交成功后调用。
// 只随接口响应返回,不落库。 // 失败导入不写记录,逐行错误明细只随接口响应返回,不落库。
func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error { func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error {
result, err := database.DB.Exec( 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 (?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@ -18,6 +19,13 @@ type MaterialImportService struct {
importRepo *repository.MaterialImportLogRepo importRepo *repository.MaterialImportLogRepo
} }
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
type materialImportItem struct {
*model.Material
categoryLabel string
unitLabel string
}
func NewMaterialImportService() *MaterialImportService { func NewMaterialImportService() *MaterialImportService {
return &MaterialImportService{ return &MaterialImportService{
materialRepo: repository.NewMaterialRepo(), materialRepo: repository.NewMaterialRepo(),
@ -32,7 +40,7 @@ func NewMaterialImportService() *MaterialImportService {
// - 模板或文件不合法:返回 nil 与错误,前端提示原因; // - 模板或文件不合法:返回 nil 与错误,前端提示原因;
// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。 // - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
// //
// 三种情况都会写一条导入记录。 // 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
if organizationID <= 0 { if organizationID <= 0 {
return nil, ErrInvalidOrganizationID return nil, ErrInvalidOrganizationID
@ -40,13 +48,11 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
rows, err := ParseMaterialImportFile(r) rows, err := ParseMaterialImportFile(r)
if err != nil { if err != nil {
s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error())
return nil, err return nil, err
} }
materials, importErrors, err := s.validateRows(organizationID, rows) materials, importErrors, err := s.validateRows(organizationID, rows)
if err != nil { if err != nil {
s.recordImport(organizationID, operator, fileName, len(rows), 0, len(rows), model.MaterialImportStatusFailed, err.Error())
return nil, err return nil, err
} }
if len(importErrors) > 0 { if len(importErrors) > 0 {
@ -56,13 +62,10 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
FailedRows: len(importErrors), FailedRows: len(importErrors),
Errors: importErrors, Errors: importErrors,
} }
s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows,
model.MaterialImportStatusFailed, summarizeImportErrors(importErrors))
return result, nil return result, nil
} }
if err := s.persist(organizationID, operator, materials); err != 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 return nil, err
} }
@ -72,13 +75,13 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
FailedRows: 0, FailedRows: 0,
Errors: []model.MaterialImportError{}, 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 return result, nil
} }
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级 // validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
// 错误;逐行的问题以 importErrors 返回,两者互斥。 // 错误;逐行的问题以 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) categories, err := s.masterRepo.ListCategories(organizationID)
if err != nil { if err != nil {
return nil, nil, err 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) importErrors := make([]model.MaterialImportError, 0)
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上, // 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
// 逐行去查会把一次导入放大成两万次往返。 // 逐行去查会把一次导入放大成两万次往返。
@ -216,7 +219,7 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
baseUnitID = &id baseUnitID = &id
} }
categoryID := minor.ID categoryID := minor.ID
materials = append(materials, &model.Material{ materials = append(materials, &materialImportItem{Material: &model.Material{
OrganizationID: organizationID, OrganizationID: organizationID,
Name: name, Name: name,
Spec: row.Spec, Spec: row.Spec,
@ -225,31 +228,46 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
Unit: unitName, Unit: unitName,
Status: "active", Status: "active",
Version: 1, Version: 1,
}) }, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
} }
return materials, importErrors, nil return materials, importErrors, nil
} }
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。 // 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() tx, err := repository.BeginTx()
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = tx.Rollback() }() 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 return err
} }
logs := make([]*model.MaterialLog, 0, len(materials)) logs := make([]*model.MaterialLog, 0, len(materials))
for _, material := range 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{ logs = append(logs, &model.MaterialLog{
OperationType: "CREATE", OperationType: "CREATE",
MaterialID: material.ID, MaterialID: material.ID,
NewValue: string(newValue),
Operator: operator, 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 { 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() return tx.Commit()
} }
// recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 // recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
// 变成失败,所以这里吞掉错误。 // 变成失败,所以这里吞掉错误。
func (s *MaterialImportService) recordImport(organizationID int64, operator, fileName string, total, success, failed int, status, reason string) { func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) {
if len([]rune(reason)) > 1000 {
reason = string([]rune(reason)[:1000])
}
_ = s.importRepo.Create(&model.MaterialImportLog{ _ = s.importRepo.Create(&model.MaterialImportLog{
OrganizationID: organizationID, OrganizationID: organizationID,
Operator: operator, Operator: operator,
FileName: fileName, FileName: fileName,
TotalRows: total, TotalRows: total,
SuccessRows: success, SuccessRows: success,
FailedRows: failed, FailedRows: 0,
Status: status, Status: model.MaterialImportStatusSuccess,
FailureReason: reason, 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 { func minorCategoryKey(majorID int64, minorName string) string {
return fmt.Sprintf("%d\x00%s", majorID, minorName) return fmt.Sprintf("%d\x00%s", majorID, minorName)
} }

View File

@ -116,8 +116,12 @@ func guideHeaderStyle(f *excelize.File) int {
// 或缺少「物料数据」表时才判定模板不合法。 // 或缺少「物料数据」表时才判定模板不合法。
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { 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 err != nil {
if f != nil {
_ = f.Close()
}
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
defer f.Close() defer f.Close()
@ -127,24 +131,35 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
all, err := f.GetRows(MaterialImportSheetData) iterator, err := f.Rows(MaterialImportSheetData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(all) == 0 { defer iterator.Close()
if !iterator.Next() {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
columns := materialImportColumnIndex(all[0]) header, err := iterator.Columns()
if err != nil {
return nil, err
}
columns := materialImportColumnIndex(header)
for _, required := range materialImportRequiredHeaders { for _, required := range materialImportRequiredHeaders {
if _, ok := columns[required]; !ok { if _, ok := columns[required]; !ok {
return nil, ErrImportTemplateInvalid return nil, ErrImportTemplateInvalid
} }
} }
rows := make([]model.MaterialImportRow, 0, len(all)-1) rows := make([]model.MaterialImportRow, 0, 256)
for i, raw := range all[1:] { rowNumber := 1
for iterator.Next() {
rowNumber++
raw, err := iterator.Columns()
if err != nil {
return nil, err
}
row := model.MaterialImportRow{ row := model.MaterialImportRow{
Row: i + 2, // Excel 行号:表头占第 1 行 Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
Name: materialImportColumnValue(raw, columns, "物料名称"), Name: materialImportColumnValue(raw, columns, "物料名称"),
Spec: materialImportColumnValue(raw, columns, "规格"), Spec: materialImportColumnValue(raw, columns, "规格"),
Unit: 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 == "" { if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
continue continue
} }
if len(rows) == MaterialImportMaxRows {
return nil, ErrImportTooManyRows
}
rows = append(rows, row) rows = append(rows, row)
} }
if len(rows) > MaterialImportMaxRows { if err := iterator.Error(); err != nil {
return nil, ErrImportTooManyRows return nil, err
}
if err := iterator.Close(); err != nil {
return nil, err
} }
return rows, nil return rows, nil
} }

View File

@ -1,13 +1,70 @@
package service package service
import ( import (
"archive/zip"
"bytes" "bytes"
"errors" "errors"
"io"
"testing" "testing"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
) )
func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows+1)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
data := buildTestWorkbook(t, dataRows)
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatal(err)
}
var changed bytes.Buffer
writer := zip.NewWriter(&changed)
for _, entry := range reader.File {
source, err := entry.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(source)
source.Close()
if err != nil {
t.Fatal(err)
}
if entry.Name == "xl/worksheets/sheet1.xml" {
// 超限行之后故意放非法 XML,证明解析器不会再读取后续行。
content = bytes.Replace(content, []byte("</sheetData>"),
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
}
target, err := writer.Create(entry.Name)
if err != nil {
t.Fatal(err)
}
if _, err := target.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
}
}
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
dataRows := make([][]string, MaterialImportMaxRows)
for i := range dataRows {
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
}
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
if err != nil || len(rows) != MaterialImportMaxRows {
t.Fatalf("rows=%d, err=%v", len(rows), err)
}
}
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) { func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},

View File

@ -2,6 +2,9 @@ package service
import ( import (
"bytes" "bytes"
"database/sql/driver"
"encoding/json"
"errors"
"regexp" "regexp"
"strings" "strings"
"testing" "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{}) { func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64), // 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
// sqlmock 按类型比对,传 Go int 匹配不上。 // sqlmock 按类型比对,传 Go int 匹配不上。
@ -56,12 +58,45 @@ func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, succe
WillReturnResult(sqlmock.NewResult(9, 1)) 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) { func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
mock, cleanup := newImportServiceMock(t) mock, cleanup := newImportServiceMock(t)
defer cleanup() defer cleanup()
expectImportMasterData(mock, true) expectImportMasterData(mock, true)
expectImportLog(mock, "物料.xlsx", "failed", 2, 0, 2, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"", "", "", "机械结构类", "标准件"}, // 名称缺失 {"", "", "", "机械结构类", "标准件"}, // 名称缺失
@ -80,9 +115,6 @@ func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing
if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") { if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") {
t.Fatalf("second error = %+v", result.Errors[1]) t.Fatalf("second error = %+v", result.Errors[1])
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
@ -90,7 +122,7 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
defer cleanup() defer cleanup()
expectImportMasterData(mock, false) expectImportMasterData(mock, false)
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"螺栓", "", "个", "机械结构类", "标准件"}, {"螺栓", "", "个", "机械结构类", "标准件"},
@ -102,16 +134,13 @@ func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") {
t.Fatalf("result = %+v", result) t.Fatalf("result = %+v", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
mock, cleanup := newImportServiceMock(t) mock, cleanup := newImportServiceMock(t)
defer cleanup() 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")) result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx"))
if err == nil { if err == nil {
@ -120,9 +149,6 @@ func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
if result != nil { if result != nil {
t.Fatalf("result = %+v, want nil", result) t.Fatalf("result = %+v, want nil", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
@ -164,7 +190,7 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
// 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚 // 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}))
mock.ExpectRollback() mock.ExpectRollback()
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg()) defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
@ -176,13 +202,38 @@ func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
if result != nil { if result != nil {
t.Fatalf("result = %+v, want nil", result) t.Fatalf("result = %+v, want nil", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号 // expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号
// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。 // → 插入 → 回查 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) { func expectImportPersist(mock sqlmock.Sqlmock, code string) {
mock.ExpectBegin() 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`)). 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 \(\?\)`). mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
WithArgs(int64(1), code). WithArgs(int64(1), code).
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, 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() mock.ExpectCommit()
} }
@ -265,7 +320,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
AddRow(1, 1, "个", "个", 0, "active")) AddRow(1, 1, "个", "个", 0, "active"))
// 大类查不到,小类不再定位,也就不会去查编码规则 // 大类查不到,小类不再定位,也就不会去查编码规则
expectImportLog(mock, "物料.xlsx", "failed", 1, 0, 1, sqlmock.AnyArg())
defer expectNoImportLog(t, mock)()
data := buildTestWorkbook(t, [][]string{ data := buildTestWorkbook(t, [][]string{
{"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"}, {"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"},
@ -279,9 +335,6 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") { if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") {
t.Fatalf("result = %+v", result) t.Fatalf("result = %+v", result)
} }
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
} }
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是 // 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是