fix(material-import): 完善批量导入异常提示,仅保存成功历史,补齐审计日志名称、编码、详情及物料快照。上传解析前限制请求大小,逐行读取提前拦截超限数据,保留空行和真实行号规则。补充回归测试,同步计划、接口说明及八条人工用例。
This commit is contained in:
parent
2e526be3fb
commit
0ddda5363e
|
|
@ -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 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
|
||||
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
|
||||
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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("</sheetData>"),
|
||||
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
|
||||
}
|
||||
target, err := writer.Create(entry.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
|
||||
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
|
||||
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
|
||||
dataRows := make([][]string, MaterialImportMaxRows)
|
||||
for i := range dataRows {
|
||||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
|
||||
if err != nil || len(rows) != MaterialImportMaxRows {
|
||||
t.Fatalf("rows=%d, err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||
|
|
@ -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`,没有中间态;保留已有表结构兼容历史数据。成功物料操作沿用现有日志页查看。
|
||||
|
|
|
|||
|
|
@ -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(人工测试)。
|
||||
|
|
|
|||
45
frontend/src/views/Logs.test.js
Normal file
45
frontend/src/views/Logs.test.js
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -72,7 +72,9 @@
|
|||
<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="时间">{{ 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>
|
||||
<div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare">
|
||||
<h4>变更对比</h4>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, '批量导入失败'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("</sheetData>"),
|
||||
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
|
||||
}
|
||||
target, err := writer.Create(entry.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
|
||||
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
|
||||
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
|
||||
dataRows := make([][]string, MaterialImportMaxRows)
|
||||
for i := range dataRows {
|
||||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
|
||||
if err != nil || len(rows) != MaterialImportMaxRows {
|
||||
t.Fatalf("rows=%d, err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user