From 84e7f4d7d043e0dd5e5aaf54e190499464ed07a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Sat, 10 Oct 2026 14:50:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=89=A9=E6=96=99?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=AE=B0=E5=BD=95=E8=A1=A8=E4=B8=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-10-material-bulk-import.md | 2303 +++++++++-------- internal/model/material_import.go | 60 + .../2026-10-10-material-bulk-import.sql | 4 + 3 files changed, 1266 insertions(+), 1101 deletions(-) create mode 100644 internal/model/material_import.go create mode 100644 scripts/upgrades/2026-10-10-material-bulk-import.sql diff --git a/docs/superpowers/plans/2026-10-10-material-bulk-import.md b/docs/superpowers/plans/2026-10-10-material-bulk-import.md index 43fa590f..d2f5497f 100644 --- a/docs/superpowers/plans/2026-10-10-material-bulk-import.md +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -19,7 +19,7 @@ - 任意一行校验失败 → 整批不入库,返回全部逐行错误。 - 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。 - 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。 -- 提交只包含本功能涉及的文件;工作区已有的「默认单位 pcs→个」在途改动(`frontend/src/views/materialForm.js`、`internal/service/material.go` 等 7 个文件)**不要一起提交**。 +- --- @@ -38,16 +38,23 @@ - `填写说明`:字段名、是否必填、示例、填写规则。 - `物料数据`:第 1 行表头,数据从第 2 行开始。 -表头固定 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时按「去空格 + 去尾部 `*`」比对表头,不匹配即报「模板格式不正确,请重新下载最新模板」。 +模板生成的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**: + +- 必填列 `物料名称`、`大类`、`小类` 缺任何一列 → 报「模板格式不正确,请重新下载最新模板」; +- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理; +- 列顺序可以随意调整; +- 表头允许带首尾空格与末尾的 `*` 必填标记;不认识的列忽略。 + +「单元格留空」和「整列缺失」是两回事:规格与单位留空属于合法的选填,不会报错;只有必填列缺失才判定模板不合法。 分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 ### 3. 接口 -| 方法 | 路径 | 说明 | -| --- | --- | --- | -| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 | -| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 | +| 方法 | 路径 | 说明 | +| ---- | ------------------------------- | -------------------------------------- | +| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 | +| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 | **路由必须挂在独立前缀 `/material-import`**:`materials` 组下已有 `GET /:id`,gin 的路由树不允许同一段既有静态前缀又有 `:id` 通配,把 `/materials/import/...` 加进该组会直接 panic。 @@ -80,13 +87,13 @@ ### 4. 校验规则(逐行) -| 列 | 规则 | -| --- | --- | -| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | -| 规格 | 选填,≤ 200 字 | -| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | -| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | -| 小类 | 必填,必须是该大类下已启用的小类名称 | +| 列 | 规则 | +| ---- | ------------------------------------------- | +| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | +| 规格 | 选填,≤ 200 字 | +| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | +| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | +| 小类 | 必填,必须是该大类下已启用的小类名称 | | 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 | 同一行可产生多条原因,用中文分号连接。 @@ -123,54 +130,57 @@ **新建:** -| 文件 | 职责 | -| --- | --- | -| `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/handler/material_import.go` | 两个 HTTP handler | -| `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 | -| `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) | -| `frontend/src/components/business/MaterialImportResult.vue` | 导入结果弹窗(摘要 + 错误表格 + 分页 + 导出) | +| 文件 | 职责 | +| ----------------------------------------------------------- | ----------------------------- | +| `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/handler/material_import.go` | 两个 HTTP handler | +| `scripts/upgrades/2026-10-10-material-bulk-import.sql` | 已部署库的增量升级脚本 | +| `frontend/src/utils/exportCsv.js` | 前端生成 CSV(UTF-8 BOM) | +| `frontend/src/components/business/MaterialImportResult.vue` | 导入结果弹窗(摘要 + 错误表格 + 分页 + 导出) | **修改:** -| 文件 | 改动 | -| --- | --- | -| `go.mod` / `go.sum` | 新增 `excelize/v2 v2.8.1` | -| `scripts/init.sql` | 新增 `material_import_logs` 表 | -| `internal/repository/material.go` | 新增 `ImportTx`(批量分配编码 + 插入) | -| `internal/repository/material_log.go` | 新增 `CreateBatchTx` | -| `internal/handler/api.go` | 注册 `/material-import` 路由组 | -| `pkg/response/response.go` | 新增 `ErrorWithData`(带 data 的错误响应) | -| `frontend/src/api/materials.js` | 新增导入上传与模板下载 | -| `frontend/src/views/Materials.vue` | 顶部两个按钮 + 上传 + 结果弹窗挂载 | -| `internal/handler/routes_test.go` | 补两条路由断言 | -| `docs/api.md` | 补接口文档 | +| 文件 | 改动 | +| ------------------------------------- | -------------------------------- | +| `go.mod` / `go.sum` | 新增 `excelize/v2 v2.8.1` | +| `scripts/init.sql` | 新增 `material_import_logs` 表 | +| `internal/repository/material.go` | 新增 `ImportTx`(批量分配编码 + 插入) | +| `internal/repository/material_log.go` | 新增 `CreateBatchTx` | +| `internal/handler/api.go` | 注册 `/material-import` 路由组 | +| `pkg/response/response.go` | 新增 `ErrorWithData`(带 data 的错误响应) | +| `frontend/src/api/materials.js` | 新增导入上传与模板下载 | +| `frontend/src/views/Materials.vue` | 顶部两个按钮 + 上传 + 结果弹窗挂载 | +| `internal/handler/routes_test.go` | 补两条路由断言 | +| `docs/api.md` | 补接口文档 | **测试:** -| 文件 | 覆盖 | -| --- | --- | -| `internal/service/material_import_excel_test.go` | 模板生成、解析、表头不符、超行数、空行跳过 | -| `internal/service/material_import_test.go` | 逐行校验各分支、整批不入库、成功入库 | -| `internal/repository/material_import_test.go` | 日志写入 | -| `internal/handler/material_import_test.go` | 路由权限、响应结构与状态码 | -| `frontend/src/utils/exportCsv.test.js` | CSV 转义与 BOM | -| `frontend/src/components/business/MaterialImportResult.test.js` | 摘要渲染、错误表格、导出调用 | +| 文件 | 覆盖 | +| --------------------------------------------------------------- | --------------------- | +| `internal/service/material_import_excel_test.go` | 模板生成、解析、表头不符、超行数、空行跳过 | +| `internal/service/material_import_test.go` | 逐行校验各分支、整批不入库、成功入库 | +| `internal/repository/material_import_test.go` | 日志写入 | +| `internal/handler/material_import_test.go` | 路由权限、响应结构与状态码 | +| `frontend/src/utils/exportCsv.test.js` | CSV 转义与 BOM | +| `frontend/src/components/business/MaterialImportResult.test.js` | 摘要渲染、错误表格、导出调用 | --- ## Task 1: 数据库表与模型 **Files:** + - Create: `internal/model/material_import.go` - Create: `scripts/upgrades/2026-10-10-material-bulk-import.sql` - Modify: `scripts/init.sql`(在 `code_rules` 建表语句之后追加) **Interfaces:** + - Produces: `model.MaterialImportRow`、`model.MaterialImportError`、`model.MaterialImportResult`、`model.MaterialImportLog` + - Produces: 表 `material_import_logs` - [ ] **Step 1: 在 init.sql 增加建表语句** @@ -201,58 +211,58 @@ import "time" // MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1 // 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。 type MaterialImportRow struct { - Row int - Name string - Spec string - Unit string - MajorName string - MinorName string + Row int + Name string + Spec string + Unit string + MajorName string + MinorName string } // Data 返回该行的原始内容,用于错误明细里的"原始数据"。 func (r MaterialImportRow) Data() map[string]string { - return map[string]string{ - "name": r.Name, - "spec": r.Spec, - "unit": r.Unit, - "major": r.MajorName, - "minor": r.MinorName, - } + return map[string]string{ + "name": r.Name, + "spec": r.Spec, + "unit": r.Unit, + "major": r.MajorName, + "minor": r.MinorName, + } } // MaterialImportError 是一行的失败原因,Reason 可能包含多条,用中文分号连接。 type MaterialImportError struct { - Row int `json:"row"` - Data map[string]string `json:"data"` - Reason string `json:"reason"` + Row int `json:"row"` + Data map[string]string `json:"data"` + Reason string `json:"reason"` } // MaterialImportResult 是一次导入的返回值。校验失败时 SuccessRows 为 0、 // Errors 非空;成功时 Errors 为空数组(不是 null)。 type MaterialImportResult struct { - TotalRows int `json:"total_rows"` - SuccessRows int `json:"success_rows"` - FailedRows int `json:"failed_rows"` - Errors []MaterialImportError `json:"errors"` + TotalRows int `json:"total_rows"` + SuccessRows int `json:"success_rows"` + FailedRows int `json:"failed_rows"` + Errors []MaterialImportError `json:"errors"` } // MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 type MaterialImportLog struct { - ID int64 `json:"id"` - OrganizationID int64 `json:"organization_id"` - Operator string `json:"operator"` - FileName string `json:"file_name"` - TotalRows int `json:"total_rows"` - SuccessRows int `json:"success_rows"` - FailedRows int `json:"failed_rows"` - Status string `json:"status"` - FailureReason string `json:"failure_reason"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + OrganizationID int64 `json:"organization_id"` + Operator string `json:"operator"` + FileName string `json:"file_name"` + TotalRows int `json:"total_rows"` + SuccessRows int `json:"success_rows"` + FailedRows int `json:"failed_rows"` + Status string `json:"status"` + FailureReason string `json:"failure_reason"` + CreatedAt time.Time `json:"created_at"` } const ( - MaterialImportStatusSuccess = "success" - MaterialImportStatusFailed = "failed" + MaterialImportStatusSuccess = "success" + MaterialImportStatusFailed = "failed" ) ``` @@ -278,13 +288,17 @@ git commit -m "feat: 新增物料导入记录表与模型" ## Task 2: Excel 模板生成与解析 **Files:** + - Create: `internal/service/material_import_excel.go` - Test: `internal/service/material_import_excel_test.go` - Modify: `go.mod`、`go.sum` **Interfaces:** + - Produces: `BuildMaterialImportTemplate() ([]byte, error)` + - Produces: `ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error)` + - Produces: 错误变量 `ErrImportTemplateInvalid`、`ErrImportTooManyRows` - [ ] **Step 1: 引入 excelize v2.8.1** @@ -304,131 +318,169 @@ Expected: `github.com/xuri/excelize/v2 v2.8.1`(**不可为 v2.9+**) package service import ( - "bytes" - "errors" - "testing" + "bytes" + "errors" + "testing" - "github.com/xuri/excelize/v2" + "github.com/xuri/excelize/v2" ) func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) { - data := buildTestWorkbook(t, [][]string{ - {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, - {"", "", "", "", ""}, // 整行空,应被跳过 - {"急停按钮", "XB2", "个", "电气元器件类", "成品件"}, - }) + data := buildTestWorkbook(t, [][]string{ + {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, + {"", "", "", "", ""}, // 整行空,应被跳过 + {"急停按钮", "XB2", "个", "电气元器件类", "成品件"}, + }) - rows, err := ParseMaterialImportFile(bytes.NewReader(data)) - if err != nil { - t.Fatalf("parse: %v", err) - } - if len(rows) != 2 { - t.Fatalf("rows = %d, want 2", len(rows)) - } - if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" { - t.Fatalf("first row = %+v", rows[0]) - } - // 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行 - if rows[1].Row != 4 || rows[1].Name != "急停按钮" { - t.Fatalf("second row = %+v", rows[1]) - } + rows, err := ParseMaterialImportFile(bytes.NewReader(data)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" { + t.Fatalf("first row = %+v", rows[0]) + } + // 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行 + if rows[1].Row != 4 || rows[1].Name != "急停按钮" { + t.Fatalf("second row = %+v", rows[1]) + } } func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) { - // 名称填了、其余为空:不是空行,必须保留交给校验去报错 - data := buildTestWorkbook(t, [][]string{ - {"只有名称", "", "", "", ""}, - }) - rows, err := ParseMaterialImportFile(bytes.NewReader(data)) - if err != nil { - t.Fatalf("parse: %v", err) - } - if len(rows) != 1 || rows[0].Name != "只有名称" { - t.Fatalf("rows = %+v", rows) - } + // 名称填了、其余为空:不是空行,必须保留交给校验去报错 + data := buildTestWorkbook(t, [][]string{ + {"只有名称", "", "", "", ""}, + }) + rows, err := ParseMaterialImportFile(bytes.NewReader(data)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 || rows[0].Name != "只有名称" { + t.Fatalf("rows = %+v", rows) + } } -func TestParseMaterialImportFileRejectsWrongHeader(t *testing.T) { - f := excelize.NewFile() - defer f.Close() - f.SetSheetName("Sheet1", MaterialImportSheetData) - f.SetCellValue(MaterialImportSheetData, "A1", "物料名称") - f.SetCellValue(MaterialImportSheetData, "B1", "规格") - buf, err := f.WriteToBuffer() - if err != nil { - t.Fatalf("build workbook: %v", err) - } +func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) { + // 只有「物料名称」和「规格」,缺必填的大类、小类 + data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{ + {"内六角螺栓", "M8x30"}, + }) - _, err = ParseMaterialImportFile(bytes.NewReader(buf.Bytes())) - if !errors.Is(err, ErrImportTemplateInvalid) { - t.Fatalf("err = %v, want ErrImportTemplateInvalid", err) - } + _, err := ParseMaterialImportFile(bytes.NewReader(data)) + if !errors.Is(err, ErrImportTemplateInvalid) { + t.Fatalf("err = %v, want ErrImportTemplateInvalid", err) + } +} + +func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) { + // 删掉「规格」,并把「单位」挪到「物料名称」前面 + headers := []string{"单位", "物料名称", "大类", "小类"} + data := buildTestWorkbookWithHeaders(t, headers, [][]string{ + {"个", "内六角螺栓", "机械结构类", "标准件"}, + }) + + rows, err := ParseMaterialImportFile(bytes.NewReader(data)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" { + t.Fatalf("row = %+v", rows[0]) + } + if rows[0].Spec != "" { + t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec) + } +} + +func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) { + headers := []string{"物料名称", "备注", "大类", "小类"} + data := buildTestWorkbookWithHeaders(t, headers, [][]string{ + {"内六角螺栓", "与导入无关", "机械结构类", "标准件"}, + }) + + rows, err := ParseMaterialImportFile(bytes.NewReader(data)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rows) != 1 || rows[0].Name != "内六角螺栓" { + t.Fatalf("rows = %+v", rows) + } } func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) { - rows := make([][]string, 0, MaterialImportMaxRows+1) - for i := 0; i < MaterialImportMaxRows+1; i++ { - rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"}) - } - data := buildTestWorkbook(t, rows) + rows := make([][]string, 0, MaterialImportMaxRows+1) + for i := 0; i < MaterialImportMaxRows+1; i++ { + rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"}) + } + data := buildTestWorkbook(t, rows) - _, err := ParseMaterialImportFile(bytes.NewReader(data)) - if !errors.Is(err, ErrImportTooManyRows) { - t.Fatalf("err = %v, want ErrImportTooManyRows", err) - } + _, err := ParseMaterialImportFile(bytes.NewReader(data)) + if !errors.Is(err, ErrImportTooManyRows) { + t.Fatalf("err = %v, want ErrImportTooManyRows", err) + } } func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) { - data, err := BuildMaterialImportTemplate() - if err != nil { - t.Fatalf("build template: %v", err) - } - f, err := excelize.OpenReader(bytes.NewReader(data)) - if err != nil { - t.Fatalf("open template: %v", err) - } - defer f.Close() + data, err := BuildMaterialImportTemplate() + if err != nil { + t.Fatalf("build template: %v", err) + } + f, err := excelize.OpenReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("open template: %v", err) + } + defer f.Close() - if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 { - t.Fatal("模板缺少「物料数据」工作表") - } - if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 { - t.Fatal("模板缺少「填写说明」工作表") - } - headers, err := f.GetRows(MaterialImportSheetData) - if err != nil { - t.Fatalf("read rows: %v", err) - } - if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" { - t.Fatalf("表头 = %v", headers) - } - // 模板必须能被自己的解析器接受(表头一致) - if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil { - t.Fatalf("模板无法被解析器接受: %v", err) - } + if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 { + t.Fatal("模板缺少「物料数据」工作表") + } + if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 { + t.Fatal("模板缺少「填写说明」工作表") + } + headers, err := f.GetRows(MaterialImportSheetData) + if err != nil { + t.Fatalf("read rows: %v", err) + } + if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" { + t.Fatalf("表头 = %v", headers) + } + // 模板必须能被自己的解析器接受(表头一致) + if _, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil { + t.Fatalf("模板无法被解析器接受: %v", err) + } } -// buildTestWorkbook 用「物料数据」表头 + 给定数据行构造一个 xlsx 字节流。 +// buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。 func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte { - t.Helper() - f := excelize.NewFile() - defer f.Close() - f.SetSheetName("Sheet1", MaterialImportSheetData) - for i, header := range materialImportHeaders { - cell, _ := excelize.CoordinatesToCellName(i+1, 1) - f.SetCellValue(MaterialImportSheetData, cell, header) - } - for r, row := range dataRows { - for c, value := range row { - cell, _ := excelize.CoordinatesToCellName(c+1, r+2) - f.SetCellValue(MaterialImportSheetData, cell, value) - } - } - buf, err := f.WriteToBuffer() - if err != nil { - t.Fatalf("write workbook: %v", err) - } - return buf.Bytes() + t.Helper() + return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows) +} + +// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。 +func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte { + t.Helper() + f := excelize.NewFile() + defer f.Close() + f.SetSheetName("Sheet1", MaterialImportSheetData) + for i, header := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + f.SetCellValue(MaterialImportSheetData, cell, header) + } + for r, row := range dataRows { + for c, value := range row { + cell, _ := excelize.CoordinatesToCellName(c+1, r+2) + f.SetCellValue(MaterialImportSheetData, cell, value) + } + } + buf, err := f.WriteToBuffer() + if err != nil { + t.Fatalf("write workbook: %v", err) + } + return buf.Bytes() } ``` @@ -445,172 +497,198 @@ Expected: FAIL / 编译错误 `undefined: ParseMaterialImportFile` package service import ( - "errors" - "fmt" - "io" - "strings" + "errors" + "fmt" + "io" + "strings" - "github.com/wmms/wmms/internal/model" - "github.com/xuri/excelize/v2" + "github.com/wmms/wmms/internal/model" + "github.com/xuri/excelize/v2" ) const ( - // MaterialImportSheetData 是填写数据的表名,「物料数据」。 - MaterialImportSheetData = "物料数据" - // MaterialImportSheetGuide 是填写说明的表名。 - MaterialImportSheetGuide = "填写说明" - // MaterialImportMaxRows 是数据区最大行数(不含表头)。 - MaterialImportMaxRows = 20000 - // MaterialImportMaxBytes 是上传文件大小上限。 - MaterialImportMaxBytes = 5 << 20 + // MaterialImportSheetData 是填写数据的表名,「物料数据」。 + MaterialImportSheetData = "物料数据" + // MaterialImportSheetGuide 是填写说明的表名。 + MaterialImportSheetGuide = "填写说明" + // MaterialImportMaxRows 是数据区最大行数(不含表头)。 + MaterialImportMaxRows = 20000 + // MaterialImportMaxBytes 是上传文件大小上限。 + MaterialImportMaxBytes = 5 << 20 ) var ( - // ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。 - ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板") - // ErrImportTooManyRows 表示数据行数超出上限。 - ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows) + // ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。 + ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板") + // ErrImportTooManyRows 表示数据行数超出上限。 + ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows) ) -// materialImportHeaders 是「物料数据」表的表头,顺序即列序。 +// materialImportHeaders 是生成模板时的列顺序(含选填列)。 var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"} +// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为 +// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。 +var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"} + // BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 func BuildMaterialImportTemplate() ([]byte, error) { - f := excelize.NewFile() - defer f.Close() + f := excelize.NewFile() + defer f.Close() - if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil { - return nil, err - } - if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { - return nil, err - } - // 数据表放在最前面,用户打开文件即停在填写页。 - idx, err := f.GetSheetIndex(MaterialImportSheetData) - if err != nil { - return nil, err - } - f.SetActiveSheet(idx) + if err := f.SetSheetName("Sheet1", MaterialImportSheetData); err != nil { + return nil, err + } + if _, err := f.NewSheet(MaterialImportSheetGuide); err != nil { + return nil, err + } + // 数据表放在最前面,用户打开文件即停在填写页。 + idx, err := f.GetSheetIndex(MaterialImportSheetData) + if err != nil { + return nil, err + } + f.SetActiveSheet(idx) - guide := [][]string{ - {"字段", "是否必填", "示例", "填写规则"}, - {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, - {"规格", "选填", "M8x30", "最长 200 字"}, - {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, - {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"}, - {"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"}, - {"", "", "", ""}, - {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, - } - for r, row := range guide { - for c, value := range row { - cell, err := excelize.CoordinatesToCellName(c+1, r+1) - if err != nil { - return nil, err - } - if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil { - return nil, err - } - } - } - for i := range guide[0] { - cell, _ := excelize.CoordinatesToCellName(i+1, 1) - _ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f)) - } - _ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14) - _ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24) + guide := [][]string{ + {"字段", "是否必填", "示例", "填写规则"}, + {"物料名称", "必填", "内六角螺栓", "最长 200 字"}, + {"规格", "选填", "M8x30", "最长 200 字"}, + {"单位", "选填", "个", "填本公司已启用的计量单位名称或编码;不填默认「个」"}, + {"大类", "必填", "机械结构类", "填本公司已启用的物料大类名称"}, + {"小类", "必填", "标准件", "填该大类下已启用的小类名称,且该小类必须已配置编码规则"}, + {"", "", "", ""}, + {"说明", "", "", "1. 只需填写「物料数据」表,从第 2 行开始;2. 物料编码无需填写,由系统按小类编码规则自动生成;3. 任意一行校验不通过,整批都不会导入。"}, + } + for r, row := range guide { + for c, value := range row { + cell, err := excelize.CoordinatesToCellName(c+1, r+1) + if err != nil { + return nil, err + } + if err := f.SetCellValue(MaterialImportSheetGuide, cell, value); err != nil { + return nil, err + } + } + } + for i := range guide[0] { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + _ = f.SetCellStyle(MaterialImportSheetGuide, cell, cell, guideHeaderStyle(f)) + } + _ = f.SetColWidth(MaterialImportSheetGuide, "A", "A", 14) + _ = f.SetColWidth(MaterialImportSheetGuide, "B", "D", 24) - for i, header := range materialImportHeaders { - cell, err := excelize.CoordinatesToCellName(i+1, 1) - if err != nil { - return nil, err - } - if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil { - return nil, err - } - if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil { - return nil, err - } - } - _ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18) + for i, header := range materialImportHeaders { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return nil, err + } + if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil { + return nil, err + } + if err := f.SetCellStyle(MaterialImportSheetData, cell, cell, guideHeaderStyle(f)); err != nil { + return nil, err + } + } + _ = f.SetColWidth(MaterialImportSheetData, "A", "E", 18) - buf, err := f.WriteToBuffer() - if err != nil { - return nil, err - } - return buf.Bytes(), nil + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil } // guideHeaderStyle 返回表头加粗样式;样式创建失败不影响模板可用,调用方忽略错误。 func guideHeaderStyle(f *excelize.File) int { - style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) - if err != nil { - return 0 - } - return style + style, err := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) + if err != nil { + return 0 + } + return style } // ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。 +// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列 +// 或缺少「物料数据」表时才判定模板不合法。 // 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。 func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) { - f, err := excelize.OpenReader(r) - if err != nil { - return nil, ErrImportTemplateInvalid - } - defer f.Close() + f, err := excelize.OpenReader(r) + if err != nil { + return nil, ErrImportTemplateInvalid + } + defer f.Close() - idx, err := f.GetSheetIndex(MaterialImportSheetData) - if err != nil || idx < 0 { - return nil, ErrImportTemplateInvalid - } + idx, err := f.GetSheetIndex(MaterialImportSheetData) + if err != nil || idx < 0 { + return nil, ErrImportTemplateInvalid + } - all, err := f.GetRows(MaterialImportSheetData) - if err != nil { - return nil, err - } - if len(all) == 0 || !materialImportHeaderMatches(all[0]) { - return nil, ErrImportTemplateInvalid - } + all, err := f.GetRows(MaterialImportSheetData) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, ErrImportTemplateInvalid + } + columns := materialImportColumnIndex(all[0]) + 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:] { - row := model.MaterialImportRow{ - Row: i + 2, // Excel 行号:表头占第 1 行 - Name: materialImportCell(raw, 0), - Spec: materialImportCell(raw, 1), - Unit: materialImportCell(raw, 2), - MajorName: materialImportCell(raw, 3), - MinorName: materialImportCell(raw, 4), - } - if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { - continue - } - rows = append(rows, row) - } + rows := make([]model.MaterialImportRow, 0, len(all)-1) + for i, raw := range all[1:] { + row := model.MaterialImportRow{ + Row: i + 2, // Excel 行号:表头占第 1 行 + Name: materialImportColumnValue(raw, columns, "物料名称"), + Spec: materialImportColumnValue(raw, columns, "规格"), + Unit: materialImportColumnValue(raw, columns, "单位"), + MajorName: materialImportColumnValue(raw, columns, "大类"), + MinorName: materialImportColumnValue(raw, columns, "小类"), + } + if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" { + continue + } + rows = append(rows, row) + } - if len(rows) > MaterialImportMaxRows { - return nil, ErrImportTooManyRows - } - return rows, nil + if len(rows) > MaterialImportMaxRows { + return nil, ErrImportTooManyRows + } + return rows, nil } -// materialImportHeaderMatches 比对表头:允许用户加空格,也允许末尾带 * 标记必填。 -func materialImportHeaderMatches(header []string) bool { - for i, want := range materialImportHeaders { - got := strings.TrimSuffix(strings.TrimSpace(materialImportCell(header, i)), "*") - if strings.TrimSpace(got) != want { - return false - } - } - return true +// materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加 +// 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。 +func materialImportColumnIndex(header []string) map[string]int { + index := make(map[string]int, len(header)) + for i, cell := range header { + name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*")) + if name == "" { + continue + } + if _, exists := index[name]; !exists { + index[name] = i + } + } + return index +} + +// materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。 +func materialImportColumnValue(row []string, columns map[string]int, name string) string { + index, ok := columns[name] + if !ok { + return "" + } + return materialImportCell(row, index) } func materialImportCell(row []string, index int) string { - if index >= len(row) { - return "" - } - return strings.TrimSpace(row[index]) + if index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) } ``` @@ -631,14 +709,19 @@ git commit -m "feat: 物料导入模板生成与 xlsx 解析" ## Task 3: 批量入库(编码分配 + 事务插入) **Files:** + - Modify: `internal/repository/material.go`(追加方法,不改动已有函数) - Modify: `internal/repository/material_log.go`(追加 `CreateBatchTx`) - Test: `internal/repository/material_import_test.go` **Interfaces:** + - Consumes: `model.Material`、`sqlExecer`(已存在)、`lockCodeRule`(已存在,`material.go:84`) + - Produces: `(*MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error` + - Produces: `(*MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error` + - Produces: `materialImportBatchSize = 500` - [ ] **Step 1: 写失败的测试** @@ -649,175 +732,175 @@ git commit -m "feat: 物料导入模板生成与 xlsx 解析" package repository import ( - "fmt" - "regexp" - "strings" - "testing" + "fmt" + "regexp" + "strings" + "testing" - "github.com/DATA-DOG/go-sqlmock" - "github.com/wmms/wmms/internal/model" - "github.com/wmms/wmms/pkg/database" + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" ) const importLockRuleQuery = `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` func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) { - t.Helper() - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("open sql mock: %v", err) - } - previous := database.DB - database.DB = db - return mock, func() { - database.DB = previous - db.Close() - } + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("open sql mock: %v", err) + } + previous := database.DB + database.DB = db + return mock, func() { + database.DB = previous + db.Close() + } } func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) { - mock, cleanup := newImportMock(t) - defer cleanup() + mock, cleanup := newImportMock(t) + defer cleanup() - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). - AddRow(5, 1, 12, "A01", 4, 201)) - // 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202 - mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)). - WithArgs(int64(1), "A01%"). - WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201")) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). - WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) - // 两行折叠成一条多值 INSERT - mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)). - WithArgs( - int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1, - int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1, - ). - WillReturnResult(sqlmock.NewResult(31, 2)) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)). - WithArgs(int64(1), "A010202", "A010203"). - WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}). - AddRow(31, "A010202").AddRow(32, "A010203")) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). + AddRow(5, 1, 12, "A01", 4, 201)) + // 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202 + mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)). + WithArgs(int64(1), "A01%"). + WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201")) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). + WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) + // 两行折叠成一条多值 INSERT + mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)). + WithArgs( + int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1, + int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1, + ). + WillReturnResult(sqlmock.NewResult(31, 2)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)). + WithArgs(int64(1), "A010202", "A010203"). + WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}). + AddRow(31, "A010202").AddRow(32, "A010203")) - tx, err := database.DB.Begin() - if err != nil { - t.Fatalf("begin: %v", err) - } - categoryID := int64(12) - materials := []*model.Material{ - {OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, - {OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, - } - if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { - t.Fatalf("import: %v", err) - } - if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 { - t.Fatalf("first = %+v", materials[0]) - } - if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 { - t.Fatalf("second = %+v", materials[1]) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + tx, err := database.DB.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + categoryID := int64(12) + materials := []*model.Material{ + {OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, + {OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, + } + if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { + t.Fatalf("import: %v", err) + } + if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 { + t.Fatalf("first = %+v", materials[0]) + } + if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 { + t.Fatalf("second = %+v", materials[1]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) { - mock, cleanup := newImportMock(t) - defer cleanup() + mock, cleanup := newImportMock(t) + defer cleanup() - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) - tx, err := database.DB.Begin() - if err != nil { - t.Fatalf("begin: %v", err) - } - materials := []*model.Material{ - {OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1}, - } - err = NewMaterialRepo().ImportTx(tx, 1, materials) - if err == nil || !strings.Contains(err.Error(), "编码规则") { - t.Fatalf("err = %v, want 编码规则错误", err) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + tx, err := database.DB.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + materials := []*model.Material{ + {OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1}, + } + err = NewMaterialRepo().ImportTx(tx, 1, materials) + if err == nil || !strings.Contains(err.Error(), "编码规则") { + t.Fatalf("err = %v, want 编码规则错误", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) { - // 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配, - // 只断言"分了两批"以及编码序号按批连续。 - mock, cleanup := newImportMock(t) - defer cleanup() + // 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配, + // 只断言"分了两批"以及编码序号按批连续。 + mock, cleanup := newImportMock(t) + defer cleanup() - mock.ExpectBegin() - mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). - AddRow(5, 1, 12, "A01", 4, 1)) - mock.ExpectQuery(`SELECT material_code FROM materials`). - WillReturnRows(sqlmock.NewRows([]string{"material_code"})) - mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500)) - mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500)) - mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1)) - mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501)) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). + AddRow(5, 1, 12, "A01", 4, 1)) + mock.ExpectQuery(`SELECT material_code FROM materials`). + WillReturnRows(sqlmock.NewRows([]string{"material_code"})) + mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500)) + mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500)) + mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1)) + mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501)) - tx, err := database.DB.Begin() - if err != nil { - t.Fatalf("begin: %v", err) - } - categoryID := int64(12) - materials := make([]*model.Material, 0, 501) - for i := 0; i < 501; i++ { - materials = append(materials, &model.Material{ - OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1, - }) - } - if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { - t.Fatalf("import: %v", err) - } - if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" { - t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + tx, err := database.DB.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + categoryID := int64(12) + materials := make([]*model.Material, 0, 501) + for i := 0; i < 501; i++ { + materials = append(materials, &model.Material{ + OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1, + }) + } + if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { + t.Fatalf("import: %v", err) + } + if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" { + t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } // idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。 func idRowsFor(from, to int) *sqlmock.Rows { - rows := sqlmock.NewRows([]string{"id", "material_code"}) - for i := from; i <= to; i++ { - rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i)) - } - return rows + rows := sqlmock.NewRows([]string{"id", "material_code"}) + for i := from; i <= to; i++ { + rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i)) + } + return rows } func TestMaterialLogRepoCreateBatchTx(t *testing.T) { - mock, cleanup := newImportMock(t) - defer cleanup() + mock, cleanup := newImportMock(t) + defer cleanup() - mock.ExpectBegin() - mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2)) + mock.ExpectBegin() + mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2)) - tx, err := database.DB.Begin() - if err != nil { - t.Fatalf("begin: %v", err) - } - logs := []*model.MaterialLog{ - {OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"}, - {OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"}, - } - if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil { - t.Fatalf("create batch: %v", err) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + tx, err := database.DB.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + logs := []*model.MaterialLog{ + {OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"}, + {OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"}, + } + if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil { + t.Fatalf("create batch: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } ``` @@ -847,82 +930,82 @@ const materialImportBatchSize = 500 // 编码按小类分组生成:组内先对 code_rules 行加锁(按 category_id 升序加锁, // 避免两个导入事务以不同顺序持锁而死锁),再跳过库中已占用的号段。 func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error { - if len(materials) == 0 { - return nil - } + if len(materials) == 0 { + return nil + } - // 1. 按 category_id 分组,并保证加锁顺序升序。 - type group struct { - categoryID int64 - items []*model.Material - } - index := make(map[int64]int) - groups := make([]*group, 0) - for _, m := range materials { - var categoryID int64 - if m.CategoryID != nil { - categoryID = *m.CategoryID - } - position, ok := index[categoryID] - if !ok { - position = len(groups) - index[categoryID] = position - groups = append(groups, &group{categoryID: categoryID}) - } - groups[position].items = append(groups[position].items, m) - } - sort.Slice(groups, func(i, j int) bool { return groups[i].categoryID < groups[j].categoryID }) + // 1. 按 category_id 分组,并保证加锁顺序升序。 + type group struct { + categoryID int64 + items []*model.Material + } + index := make(map[int64]int) + groups := make([]*group, 0) + for _, m := range materials { + var categoryID int64 + if m.CategoryID != nil { + categoryID = *m.CategoryID + } + position, ok := index[categoryID] + if !ok { + position = len(groups) + index[categoryID] = position + groups = append(groups, &group{categoryID: categoryID}) + } + groups[position].items = append(groups[position].items, m) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].categoryID < groups[j].categoryID }) - // 2. 逐组锁规则、分配编码。 - for _, g := range groups { - rule, err := lockCodeRule(tx, organizationID, g.categoryID) - if err != nil { - return err - } - if rule == nil { - return fmt.Errorf("小类(ID %d)未配置启用的编码规则", g.categoryID) - } + // 2. 逐组锁规则、分配编码。 + for _, g := range groups { + rule, err := lockCodeRule(tx, organizationID, g.categoryID) + if err != nil { + return err + } + if rule == nil { + return fmt.Errorf("小类(ID %d)未配置启用的编码规则", g.categoryID) + } - taken, err := loadTakenMaterialCodes(tx, organizationID, rule.Prefix) - if err != nil { - return err - } - sequence := rule.NextSequence - for _, m := range g.items { - code, next, err := nextAvailableMaterialCode(rule, sequence, taken) - if err != nil { - return err - } - m.MaterialCode = code - taken[code] = struct{}{} - sequence = next - } - if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, sequence, rule.ID); err != nil { - return err - } - } + taken, err := loadTakenMaterialCodes(tx, organizationID, rule.Prefix) + if err != nil { + return err + } + sequence := rule.NextSequence + for _, m := range g.items { + code, next, err := nextAvailableMaterialCode(rule, sequence, taken) + if err != nil { + return err + } + m.MaterialCode = code + taken[code] = struct{}{} + sequence = next + } + if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, sequence, rule.ID); err != nil { + return err + } + } - // 3. 分批多值插入,每批插入后按编码回查自增 ID。 - // 多值 INSERT 只返回首个自增 ID 且不保证连续,拿不到逐行 ID, - // 而后面写审计日志需要它;按批回查则把 IN 列表控制在 500 项以内。 - for start := 0; start < len(materials); start += materialImportBatchSize { - end := start + materialImportBatchSize - if end > len(materials) { - end = len(materials) - } - batch := materials[start:end] - query, args := buildMaterialInsertBatch(batch) - if _, err := tx.Exec(query, args...); err != nil { - if strings.Contains(err.Error(), "Duplicate entry") { - return ErrDuplicateMaterialCode - } - return err - } - if err := fillMaterialIDs(tx, organizationID, batch); err != nil { - return err - } - } - return nil + // 3. 分批多值插入,每批插入后按编码回查自增 ID。 + // 多值 INSERT 只返回首个自增 ID 且不保证连续,拿不到逐行 ID, + // 而后面写审计日志需要它;按批回查则把 IN 列表控制在 500 项以内。 + for start := 0; start < len(materials); start += materialImportBatchSize { + end := start + materialImportBatchSize + if end > len(materials) { + end = len(materials) + } + batch := materials[start:end] + query, args := buildMaterialInsertBatch(batch) + if _, err := tx.Exec(query, args...); err != nil { + if strings.Contains(err.Error(), "Duplicate entry") { + return ErrDuplicateMaterialCode + } + return err + } + if err := fillMaterialIDs(tx, organizationID, batch); err != nil { + return err + } + } + return nil } // loadTakenMaterialCodes 取出本公司该前缀下已存在的全部物料编码,供内存判重。 @@ -930,90 +1013,90 @@ func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*m // 而那些编码的格式与本规则生成的编码不同、永远不会被 FormatCode 产出, // 因此多匹配不会漏判,无需额外转义。 func loadTakenMaterialCodes(tx *sql.Tx, organizationID int64, prefix string) (map[string]struct{}, error) { - rows, err := tx.Query( - `SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`, - organizationID, prefix+"%") - if err != nil { - return nil, err - } - defer rows.Close() + rows, err := tx.Query( + `SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`, + organizationID, prefix+"%") + if err != nil { + return nil, err + } + defer rows.Close() - taken := make(map[string]struct{}) - for rows.Next() { - var code string - if err := rows.Scan(&code); err != nil { - return nil, err - } - taken[code] = struct{}{} - } - return taken, rows.Err() + taken := make(map[string]struct{}) + for rows.Next() { + var code string + if err := rows.Scan(&code); err != nil { + return nil, err + } + taken[code] = struct{}{} + } + return taken, rows.Err() } // nextAvailableMaterialCode 从 start 起找到第一个未占用的编码,返回编码与下一个 // 待用序号。探测上限沿用单条新增的 probeLimit,防止规则被用尽时无限循环。 func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[string]struct{}) (string, int64, error) { - for sequence := start; sequence < start+probeLimit; sequence++ { - code := rule.FormatCode(sequence) - if _, occupied := taken[code]; !occupied { - return code, sequence + 1, nil - } - } - return "", 0, fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix) + for sequence := start; sequence < start+probeLimit; sequence++ { + code := rule.FormatCode(sequence) + if _, occupied := taken[code]; !occupied { + return code, sequence + 1, nil + } + } + return "", 0, fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix) } // buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。 func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) { - var builder strings.Builder - builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `) - args := make([]interface{}, 0, len(batch)*11) - for i, m := range batch { - if i > 0 { - builder.WriteString(", ") - } - builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") - args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, - m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version) - } - return builder.String(), args + var builder strings.Builder + builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `) + args := make([]interface{}, 0, len(batch)*11) + for i, m := range batch { + if i > 0 { + builder.WriteString(", ") + } + builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec, + m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version) + } + return builder.String(), args } // fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID, // 而后续写 material_logs 需要它。 func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error { - codes := make([]interface{}, 0, len(materials)) - for _, m := range materials { - codes = append(codes, m.MaterialCode) - } - placeholders := strings.TrimSuffix(strings.Repeat("?,", len(codes)), ",") - query := `SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (` + placeholders + `)` - args := append([]interface{}{organizationID}, codes...) + codes := make([]interface{}, 0, len(materials)) + for _, m := range materials { + codes = append(codes, m.MaterialCode) + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(codes)), ",") + query := `SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (` + placeholders + `)` + args := append([]interface{}{organizationID}, codes...) - rows, err := tx.Query(query, args...) - if err != nil { - return err - } - defer rows.Close() + rows, err := tx.Query(query, args...) + if err != nil { + return err + } + defer rows.Close() - ids := make(map[string]int64, len(materials)) - for rows.Next() { - var id int64 - var code string - if err := rows.Scan(&id, &code); err != nil { - return err - } - ids[code] = id - } - if err := rows.Err(); err != nil { - return err - } - for _, m := range materials { - id, ok := ids[m.MaterialCode] - if !ok { - return fmt.Errorf("物料 %s 插入后未能回查到 ID", m.MaterialCode) - } - m.ID = id - } - return nil + ids := make(map[string]int64, len(materials)) + for rows.Next() { + var id int64 + var code string + if err := rows.Scan(&id, &code); err != nil { + return err + } + ids[code] = id + } + if err := rows.Err(); err != nil { + return err + } + for _, m := range materials { + id, ok := ids[m.MaterialCode] + if !ok { + return fmt.Errorf("物料 %s 插入后未能回查到 ID", m.MaterialCode) + } + m.ID = id + } + return nil } ``` @@ -1027,21 +1110,21 @@ func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Materi // CreateBatchTx 在同一事务里批量写审计日志。导入场景一条物料一条 CREATE 日志, // 逐条往返太慢,这里折叠成多值 INSERT。 func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error { - if len(logs) == 0 { - return nil - } - var builder strings.Builder - builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `) - args := make([]interface{}, 0, len(logs)*6) - for i, log := range logs { - if i > 0 { - builder.WriteString(", ") - } - builder.WriteString("(?, ?, ?, ?, ?, ?)") - args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description) - } - _, err := tx.Exec(builder.String(), args...) - return err + if len(logs) == 0 { + return nil + } + var builder strings.Builder + builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `) + args := make([]interface{}, 0, len(logs)*6) + for i, log := range logs { + if i > 0 { + builder.WriteString(", ") + } + builder.WriteString("(?, ?, ?, ?, ?, ?)") + args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description) + } + _, err := tx.Exec(builder.String(), args...) + return err } ``` @@ -1067,11 +1150,14 @@ git commit -m "feat: 物料批量入库的编码分配与事务插入" ## Task 4: 导入记录 repository **Files:** + - Create: `internal/repository/material_import.go` - Test: `internal/repository/material_import_test.go`(追加) **Interfaces:** + - Consumes: `model.MaterialImportLog` + - Produces: `(*MaterialImportLogRepo) Create(log *model.MaterialImportLog) error` - [ ] **Step 1: 写失败的测试** @@ -1080,27 +1166,27 @@ git commit -m "feat: 物料批量入库的编码分配与事务插入" ```go func TestMaterialImportLogRepoCreate(t *testing.T) { - mock, cleanup := newImportMock(t) - defer cleanup() + mock, cleanup := newImportMock(t) + defer cleanup() - mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). - WithArgs(int64(1), "tester", "物料.xlsx", 200, 0, 3, "failed", "共 3 行校验未通过"). - WillReturnResult(sqlmock.NewResult(9, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). + WithArgs(int64(1), "tester", "物料.xlsx", 200, 0, 3, "failed", "共 3 行校验未通过"). + WillReturnResult(sqlmock.NewResult(9, 1)) - log := &model.MaterialImportLog{ - OrganizationID: 1, Operator: "tester", FileName: "物料.xlsx", - TotalRows: 200, SuccessRows: 0, FailedRows: 3, - Status: "failed", FailureReason: "共 3 行校验未通过", - } - if err := NewMaterialImportLogRepo().Create(log); err != nil { - t.Fatalf("create: %v", err) - } - if log.ID != 9 { - t.Fatalf("id = %d, want 9", log.ID) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + log := &model.MaterialImportLog{ + OrganizationID: 1, Operator: "tester", FileName: "物料.xlsx", + TotalRows: 200, SuccessRows: 0, FailedRows: 3, + Status: "failed", FailureReason: "共 3 行校验未通过", + } + if err := NewMaterialImportLogRepo().Create(log); err != nil { + t.Fatalf("create: %v", err) + } + if log.ID != 9 { + t.Fatalf("id = %d, want 9", log.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } ``` @@ -1117,8 +1203,8 @@ Expected: FAIL / 编译错误 `undefined: NewMaterialImportLogRepo` package repository import ( - "github.com/wmms/wmms/internal/model" - "github.com/wmms/wmms/pkg/database" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" ) type MaterialImportLogRepo struct{} @@ -1128,14 +1214,14 @@ func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportL // 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 (?, ?, ?, ?, ?, ?, ?, ?)`, - log.OrganizationID, log.Operator, log.FileName, log.TotalRows, log.SuccessRows, log.FailedRows, log.Status, log.FailureReason) - if err != nil { - return err - } - log.ID, err = result.LastInsertId() - return err + 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 (?, ?, ?, ?, ?, ?, ?, ?)`, + log.OrganizationID, log.Operator, log.FileName, log.TotalRows, log.SuccessRows, log.FailedRows, log.Status, log.FailureReason) + if err != nil { + return err + } + log.ID, err = result.LastInsertId() + return err } ``` @@ -1156,10 +1242,12 @@ git commit -m "feat: 物料导入记录仓储" ## Task 5: 导入编排(校验 + 入库 + 记录) **Files:** + - Create: `internal/service/material_import.go` - Test: `internal/service/material_import_test.go` **Interfaces:** + - Consumes: `ParseMaterialImportFile`、`MaterialRepo.ImportTx`、`MaterialLogRepo.CreateBatchTx`、`MaterialImportLogRepo.Create`、`MasterDataRepository.ListCategories/ListUnits`、`MaterialRepo.LocklessFindActiveCodeRule` - Produces: `(*MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error)` - Produces: `NewMaterialImportService() *MaterialImportService` @@ -1174,13 +1262,13 @@ git commit -m "feat: 物料导入记录仓储" package service import ( - "bytes" - "regexp" - "strings" - "testing" + "bytes" + "regexp" + "strings" + "testing" - "github.com/DATA-DOG/go-sqlmock" - "github.com/wmms/wmms/pkg/database" + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/pkg/database" ) const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code` @@ -1188,179 +1276,179 @@ const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'` func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) { - t.Helper() - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("open sql mock: %v", err) - } - previous := database.DB - database.DB = db - return mock, func() { - database.DB = previous - db.Close() - } + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("open sql mock: %v", err) + } + previous := database.DB + database.DB = db + return mock, func() { + database.DB = previous + db.Close() + } } func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) { - mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}). - AddRow(3, 1, "A", "机械结构类", "", nil, "active"). - AddRow(12, 1, "A01", "标准件", "", 3, "active")) - mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). - AddRow(1, 1, "个", "个", 0, "active")) - if withRule { - mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). - AddRow(5, 1, 12, "A01", 4, 201, "active")) - } else { - mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) - } + mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}). + AddRow(3, 1, "A", "机械结构类", "", nil, "active"). + AddRow(12, 1, "A01", "标准件", "", 3, "active")) + mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}). + AddRow(1, 1, "个", "个", 0, "active")) + if withRule { + mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}). + AddRow(5, 1, 12, "A01", 4, 201, "active")) + } else { + mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) + } } func expectImportLog(mock sqlmock.Sqlmock, status string, total, success, failed int, reason string) { - mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). - WithArgs(int64(1), "tester", "物料.xlsx", total, success, failed, status, reason). - WillReturnResult(sqlmock.NewResult(9, 1)) + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). + WithArgs(int64(1), "tester", "物料.xlsx", total, success, failed, status, reason). + WillReturnResult(sqlmock.NewResult(9, 1)) } func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) { - mock, cleanup := newImportServiceMock(t) - defer cleanup() + mock, cleanup := newImportServiceMock(t) + defer cleanup() - expectImportMasterData(mock, true) - expectImportLog(mock, "failed", 2, 0, 2, sqlmock.AnyArg()) + expectImportMasterData(mock, true) + expectImportLog(mock, "failed", 2, 0, 2, sqlmock.AnyArg()) - data := buildTestWorkbook(t, [][]string{ - {"", "", "", "机械结构类", "标准件"}, // 名称缺失 - {"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在 - }) - result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) - if err != nil { - t.Fatalf("import: %v", err) - } - if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 { - t.Fatalf("result = %+v", result) - } - if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") { - t.Fatalf("first error = %+v", result.Errors[0]) - } - 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) - } + data := buildTestWorkbook(t, [][]string{ + {"", "", "", "机械结构类", "标准件"}, // 名称缺失 + {"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在 + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err != nil { + t.Fatalf("import: %v", err) + } + if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 { + t.Fatalf("result = %+v", result) + } + if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") { + t.Fatalf("first error = %+v", result.Errors[0]) + } + 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) { - mock, cleanup := newImportServiceMock(t) - defer cleanup() + mock, cleanup := newImportServiceMock(t) + defer cleanup() - expectImportMasterData(mock, false) - expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg()) + expectImportMasterData(mock, false) + expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg()) - data := buildTestWorkbook(t, [][]string{ - {"螺栓", "", "个", "机械结构类", "标准件"}, - }) - result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) - if err != nil { - t.Fatalf("import: %v", err) - } - if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") { - t.Fatalf("result = %+v", result) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + data := buildTestWorkbook(t, [][]string{ + {"螺栓", "", "个", "机械结构类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err != nil { + t.Fatalf("import: %v", err) + } + 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() + mock, cleanup := newImportServiceMock(t) + defer cleanup() - expectImportLog(mock, "failed", 0, 0, 0, sqlmock.AnyArg()) + expectImportLog(mock, "failed", 0, 0, 0, sqlmock.AnyArg()) - result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx")) - if err == nil { - t.Fatal("期望返回模板错误") - } - if result != nil { - t.Fatalf("result = %+v, want nil", result) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx")) + if err == nil { + t.Fatal("期望返回模板错误") + } + if result != nil { + t.Fatalf("result = %+v, want nil", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { - mock, cleanup := newImportServiceMock(t) - defer cleanup() + mock, cleanup := newImportServiceMock(t) + defer cleanup() - expectImportMasterData(mock, true) - // 事务内:锁规则 → 取已占用编码 → 插入 → 推进序号 → 回查 ID - 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`)). - WithArgs(int64(1), int64(12)). - WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). - AddRow(5, 1, 12, "A01", 4, 201)) - mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`). - WithArgs(int64(1), "A01%"). - WillReturnRows(sqlmock.NewRows([]string{"material_code"})) - mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1)) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). - WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). - WithArgs(int64(1), "A010201"). - WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, "A010201")) - mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) - mock.ExpectCommit() - expectImportLog(mock, "success", 1, 1, 0, "") + expectImportMasterData(mock, true) + // 事务内:锁规则 → 取已占用编码 → 插入 → 推进序号 → 回查 ID + 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`)). + WithArgs(int64(1), int64(12)). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). + AddRow(5, 1, 12, "A01", 4, 201)) + mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`). + WithArgs(int64(1), "A01%"). + WillReturnRows(sqlmock.NewRows([]string{"material_code"})) + mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1)) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). + WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`). + WithArgs(int64(1), "A010201"). + WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, "A010201")) + mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + expectImportLog(mock, "success", 1, 1, 0, "") - data := buildTestWorkbook(t, [][]string{ - {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, - }) - result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) - if err != nil { - t.Fatalf("import: %v", err) - } - if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 { - t.Fatalf("result = %+v", result) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + data := buildTestWorkbook(t, [][]string{ + {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err != nil { + t.Fatalf("import: %v", err) + } + if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 { + t.Fatalf("result = %+v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) { - mock, cleanup := newImportServiceMock(t) - defer cleanup() + mock, cleanup := newImportServiceMock(t) + defer cleanup() - expectImportMasterData(mock, true) - mock.ExpectBegin() - mock.ExpectQuery(`FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). - AddRow(5, 1, 12, "A01", 4, 201)) - mock.ExpectQuery(`SELECT material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"material_code"})) - mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) - mock.ExpectRollback() - expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg()) + expectImportMasterData(mock, true) + mock.ExpectBegin() + mock.ExpectQuery(`FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). + AddRow(5, 1, 12, "A01", 4, 201)) + mock.ExpectQuery(`SELECT material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"material_code"})) + mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"})) + mock.ExpectRollback() + expectImportLog(mock, "failed", 1, 0, 1, sqlmock.AnyArg()) - data := buildTestWorkbook(t, [][]string{ - {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, - }) - result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) - if err == nil { - t.Fatal("期望入库失败") - } - if result != nil { - t.Fatalf("result = %+v, want nil", result) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) - } + data := buildTestWorkbook(t, [][]string{ + {"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"}, + }) + result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data)) + if err == nil { + t.Fatal("期望入库失败") + } + if result != nil { + t.Fatalf("result = %+v, want nil", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } } ``` @@ -1377,30 +1465,30 @@ Expected: FAIL / 编译错误 `undefined: NewMaterialImportService` package service import ( - "fmt" - "io" - "strings" + "fmt" + "io" + "strings" - "github.com/wmms/wmms/internal/model" - "github.com/wmms/wmms/internal/repository" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/internal/repository" ) // MaterialImportService 负责一次物料批量导入:解析、逐行校验、事务入库、写记录。 // 首版只支持新增;校验阶段不生成编码,编码在入库事务里按小类规则分配。 type MaterialImportService struct { - materialRepo *repository.MaterialRepo - masterRepo *repository.MasterDataRepository - logRepo *repository.MaterialLogRepo - importRepo *repository.MaterialImportLogRepo + materialRepo *repository.MaterialRepo + masterRepo *repository.MasterDataRepository + logRepo *repository.MaterialLogRepo + importRepo *repository.MaterialImportLogRepo } func NewMaterialImportService() *MaterialImportService { - return &MaterialImportService{ - materialRepo: repository.NewMaterialRepo(), - masterRepo: repository.NewMasterDataRepository(), - logRepo: repository.NewMaterialLogRepo(), - importRepo: repository.NewMaterialImportLogRepo(), - } + return &MaterialImportService{ + materialRepo: repository.NewMaterialRepo(), + masterRepo: repository.NewMasterDataRepository(), + logRepo: repository.NewMaterialLogRepo(), + importRepo: repository.NewMaterialImportLogRepo(), + } } // Import 处理一次上传。返回值约定: @@ -1410,253 +1498,253 @@ func NewMaterialImportService() *MaterialImportService { // // 三种情况都会写一条导入记录。 func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { - if organizationID <= 0 { - return nil, ErrInvalidOrganizationID - } + if organizationID <= 0 { + return nil, ErrInvalidOrganizationID + } - rows, err := ParseMaterialImportFile(r) - if err != nil { - s.recordImport(organizationID, operator, fileName, 0, 0, 0, model.MaterialImportStatusFailed, err.Error()) - return nil, err - } + 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 { - result := &model.MaterialImportResult{ - TotalRows: len(rows), - SuccessRows: 0, - FailedRows: len(importErrors), - Errors: importErrors, - } - s.recordImport(organizationID, operator, fileName, result.TotalRows, 0, result.FailedRows, - model.MaterialImportStatusFailed, summarizeImportErrors(importErrors)) - return result, nil - } + 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 { + result := &model.MaterialImportResult{ + TotalRows: len(rows), + SuccessRows: 0, + 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 - } + 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 + } - result := &model.MaterialImportResult{ - TotalRows: len(rows), - SuccessRows: len(materials), - FailedRows: 0, - Errors: []model.MaterialImportError{}, - } - s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "") - return result, nil + result := &model.MaterialImportResult{ + TotalRows: len(rows), + SuccessRows: len(materials), + FailedRows: 0, + Errors: []model.MaterialImportError{}, + } + s.recordImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows, 0, model.MaterialImportStatusSuccess, "") + return result, nil } // validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级 // 错误;逐行的问题以 importErrors 返回,两者互斥。 func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*model.Material, []model.MaterialImportError, error) { - categories, err := s.masterRepo.ListCategories(organizationID) - if err != nil { - return nil, nil, err - } - units, err := s.masterRepo.ListUnits(organizationID) - if err != nil { - return nil, nil, err - } + categories, err := s.masterRepo.ListCategories(organizationID) + if err != nil { + return nil, nil, err + } + units, err := s.masterRepo.ListUnits(organizationID) + if err != nil { + return nil, nil, err + } - majors := make(map[string]*model.MaterialCategory) - minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名 - for i := range categories { - category := &categories[i] - if category.Status != "active" { - continue - } - if category.ParentID == nil { - majors[category.Name] = category - continue - } - minors[minorCategoryKey(*category.ParentID, category.Name)] = category - } - unitByName := make(map[string]*model.Unit) - unitByCode := make(map[string]*model.Unit) - for i := range units { - unit := &units[i] - if unit.Status != "active" { - continue - } - // 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果 - if _, exists := unitByName[unit.Name]; !exists { - unitByName[unit.Name] = unit - } - if _, exists := unitByCode[unit.Code]; !exists { - unitByCode[unit.Code] = unit - } - } + majors := make(map[string]*model.MaterialCategory) + minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类名 + for i := range categories { + category := &categories[i] + if category.Status != "active" { + continue + } + if category.ParentID == nil { + majors[category.Name] = category + continue + } + minors[minorCategoryKey(*category.ParentID, category.Name)] = category + } + unitByName := make(map[string]*model.Unit) + unitByCode := make(map[string]*model.Unit) + for i := range units { + unit := &units[i] + if unit.Status != "active" { + continue + } + // 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果 + if _, exists := unitByName[unit.Name]; !exists { + unitByName[unit.Name] = unit + } + if _, exists := unitByCode[unit.Code]; !exists { + unitByCode[unit.Code] = unit + } + } - materials := make([]*model.Material, 0, len(rows)) - importErrors := make([]model.MaterialImportError, 0) - // 同一小类的规则只查一次:两万行通常只落在少数几个小类上, - // 逐行去查会把一次导入放大成两万次往返。 - ruleAvailable := make(map[int64]bool) + materials := make([]*model.Material, 0, len(rows)) + importErrors := make([]model.MaterialImportError, 0) + // 同一小类的规则只查一次:两万行通常只落在少数几个小类上, + // 逐行去查会把一次导入放大成两万次往返。 + ruleAvailable := make(map[int64]bool) - for _, row := range rows { - reasons := make([]string, 0, 3) + for _, row := range rows { + reasons := make([]string, 0, 3) - name := row.Name - if name == "" { - reasons = append(reasons, "物料名称不能为空") - } else if len([]rune(name)) > 200 { - reasons = append(reasons, "物料名称超过 200 字") - } - if len([]rune(row.Spec)) > 200 { - reasons = append(reasons, "规格超过 200 字") - } + name := row.Name + if name == "" { + reasons = append(reasons, "物料名称不能为空") + } else if len([]rune(name)) > 200 { + reasons = append(reasons, "物料名称超过 200 字") + } + if len([]rune(row.Spec)) > 200 { + reasons = append(reasons, "规格超过 200 字") + } - major := majors[row.MajorName] - if row.MajorName == "" { - reasons = append(reasons, "大类不能为空") - } else if major == nil { - reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName)) - } + major := majors[row.MajorName] + if row.MajorName == "" { + reasons = append(reasons, "大类不能为空") + } else if major == nil { + reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用", row.MajorName)) + } - var minor *model.MaterialCategory - if row.MinorName == "" { - reasons = append(reasons, "小类不能为空") - } else if major == nil { - // 大类已经报错,小类无法定位,不重复报 - } else { - minor = minors[minorCategoryKey(major.ID, row.MinorName)] - if minor == nil { - reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName)) - } - } + var minor *model.MaterialCategory + if row.MinorName == "" { + reasons = append(reasons, "小类不能为空") + } else if major == nil { + // 大类已经报错,小类无法定位,不重复报 + } else { + minor = minors[minorCategoryKey(major.ID, row.MinorName)] + if minor == nil { + reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用", row.MinorName, row.MajorName)) + } + } - // 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配, - // 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。 - unitName := row.Unit - if unitName == "" { - unitName = "个" - } - unit := unitByName[unitName] - if unit == nil { - unit = unitByCode[unitName] - } - if unit == nil { - if row.Unit == "" { - reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位") - } else { - reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit)) - } - } + // 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配, + // 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。 + unitName := row.Unit + if unitName == "" { + unitName = "个" + } + unit := unitByName[unitName] + if unit == nil { + unit = unitByCode[unitName] + } + if unit == nil { + if row.Unit == "" { + reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位") + } else { + reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit)) + } + } - if minor != nil { - available, checked := ruleAvailable[minor.ID] - if !checked { - rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID) - if err != nil { - return nil, nil, err - } - available = rule != nil - ruleAvailable[minor.ID] = available - } - if !available { - reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName)) - } - } + if minor != nil { + available, checked := ruleAvailable[minor.ID] + if !checked { + rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID) + if err != nil { + return nil, nil, err + } + available = rule != nil + ruleAvailable[minor.ID] = available + } + if !available { + reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName)) + } + } - if len(reasons) > 0 { - importErrors = append(importErrors, model.MaterialImportError{ - Row: row.Row, - Data: row.Data(), - Reason: strings.Join(reasons, ";"), - }) - continue - } + if len(reasons) > 0 { + importErrors = append(importErrors, model.MaterialImportError{ + Row: row.Row, + Data: row.Data(), + Reason: strings.Join(reasons, ";"), + }) + continue + } - var baseUnitID *int64 - if unit != nil { - id := unit.ID - baseUnitID = &id - } - categoryID := minor.ID - materials = append(materials, &model.Material{ - OrganizationID: organizationID, - Name: name, - Spec: row.Spec, - CategoryID: &categoryID, - BaseUnitID: baseUnitID, - Unit: unitName, - Status: "active", - Version: 1, - }) - } + var baseUnitID *int64 + if unit != nil { + id := unit.ID + baseUnitID = &id + } + categoryID := minor.ID + materials = append(materials, &model.Material{ + OrganizationID: organizationID, + Name: name, + Spec: row.Spec, + CategoryID: &categoryID, + BaseUnitID: baseUnitID, + Unit: unitName, + Status: "active", + Version: 1, + }) + } - return materials, importErrors, nil + return materials, importErrors, nil } // persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。 func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*model.Material) error { - tx, err := repository.BeginTx() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() + tx, err := repository.BeginTx() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() - if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil { - return err - } + if err := s.materialRepo.ImportTx(tx, organizationID, materials); err != nil { + return err + } - logs := make([]*model.MaterialLog, 0, len(materials)) - for _, material := range materials { - logs = append(logs, &model.MaterialLog{ - OperationType: "CREATE", - MaterialID: material.ID, - Operator: operator, - Description: "批量导入创建", - }) - } - if err := s.logRepo.CreateBatchTx(tx, logs); err != nil { - return err - } - return tx.Commit() + logs := make([]*model.MaterialLog, 0, len(materials)) + for _, material := range materials { + logs = append(logs, &model.MaterialLog{ + OperationType: "CREATE", + MaterialID: material.ID, + Operator: operator, + Description: "批量导入创建", + }) + } + if err := s.logRepo.CreateBatchTx(tx, logs); err != nil { + return err + } + return tx.Commit() } // recordImport 写导入记录。记录失败只影响可追溯性,不应该让已经完成的导入 // 变成失败,所以这里吞掉错误。 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]) - } - _ = s.importRepo.Create(&model.MaterialImportLog{ - OrganizationID: organizationID, - Operator: operator, - FileName: fileName, - TotalRows: total, - SuccessRows: success, - FailedRows: failed, - Status: status, - FailureReason: reason, - }) + if len([]rune(reason)) > 1000 { + reason = string([]rune(reason)[:1000]) + } + _ = s.importRepo.Create(&model.MaterialImportLog{ + OrganizationID: organizationID, + Operator: operator, + FileName: fileName, + TotalRows: total, + SuccessRows: success, + FailedRows: failed, + Status: status, + FailureReason: reason, + }) } // 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 + ")" + 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) + return fmt.Sprintf("%d\x00%s", majorID, minorName) } ``` @@ -1682,14 +1770,18 @@ git commit -m "feat: 物料导入校验与事务入库编排" ## Task 6: HTTP 接口与路由 **Files:** + - Create: `internal/handler/material_import.go` - Modify: `pkg/response/response.go`(新增 `ErrorWithData`) - Modify: `internal/handler/api.go`(Handler 结构体 + 路由组) - Test: `internal/handler/material_import_test.go`、`internal/handler/routes_test.go` **Interfaces:** + - Consumes: `MaterialImportService.Import`、`BuildMaterialImportTemplate` + - Produces: `GET /api/material-import/template`、`POST /api/material-import` + - Produces: `response.ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{})` - [ ] **Step 1: 加通用错误响应** @@ -1700,11 +1792,11 @@ git commit -m "feat: 物料导入校验与事务入库编排" // ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误 // 一起带回去,不能只给一句话。 func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) { - c.JSON(httpStatus, Response{ - Code: httpStatus, - Message: message, - Data: data, - }) + c.JSON(httpStatus, Response{ + Code: httpStatus, + Message: message, + Data: data, + }) } ``` @@ -1716,37 +1808,37 @@ func ErrorWithData(c *gin.Context, httpStatus int, message string, data interfac package handler import ( - "net/http" - "net/http/httptest" - "testing" + "net/http" + "net/http/httptest" + "testing" - "github.com/gin-gonic/gin" - "github.com/wmms/wmms/pkg/response" + "github.com/gin-gonic/gin" + "github.com/wmms/wmms/pkg/response" ) func TestErrorWithDataKeepsPayload(t *testing.T) { - gin.SetMode(gin.TestMode) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) - response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{ - "total_rows": 1, "failed_rows": 1, - }) + response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{ + "total_rows": 1, "failed_rows": 1, + }) - if recorder.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", recorder.Code) - } - body := recorder.Body.String() - if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) { - t.Fatalf("body = %s", body) - } + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } + body := recorder.Body.String() + if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) { + t.Fatalf("body = %s", body) + } } ``` 并在 `internal/handler/routes_test.go` 的 `TestPhaseOneAndLegacyRoutesAreRegistered` 里,往 `want` 切片的 `"POST /api/logs"` 那一行之后追加(该切片每项是 `"方法 路径"` 字符串): ```go - "GET /api/material-import/template", "POST /api/material-import", + "GET /api/material-import/template", "POST /api/material-import", ``` 同文件的 `TestOrganizationBusinessRoutesRequireHeaderBeforeHandler` 会自动遍历到这两条新路由(它们不在豁免前缀里),断言缺少 `X-Organization-ID` 时返回 400,因此只要路由挂上了 `OrganizationMiddleware()` 就会被它覆盖,无需另写权限用例。 @@ -1764,75 +1856,75 @@ Expected: FAIL(`ErrorWithData` 未定义 / 路由缺失) package handler import ( - "errors" - "net/http" - "net/url" - "strconv" + "errors" + "net/http" + "net/url" + "strconv" - "github.com/gin-gonic/gin" - "github.com/wmms/wmms/internal/middleware" - "github.com/wmms/wmms/internal/service" - "github.com/wmms/wmms/pkg/response" + "github.com/gin-gonic/gin" + "github.com/wmms/wmms/internal/middleware" + "github.com/wmms/wmms/internal/service" + "github.com/wmms/wmms/pkg/response" ) // DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成, // 不落盘,保证下载到的永远和当前校验规则一致。 func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) { - data, err := service.BuildMaterialImportTemplate() - if err != nil { - response.InternalError(c, "模板生成失败") - return - } - fileName := url.PathEscape("物料导入模板.xlsx") - c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName) - c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) + data, err := service.BuildMaterialImportTemplate() + if err != nil { + response.InternalError(c, "模板生成失败") + return + } + fileName := url.PathEscape("物料导入模板.xlsx") + c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName) + c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) } // ImportMaterials 接收上传的 xlsx,校验通过即整批入库。 // 校验失败返回 400 且 data 里带逐行错误明细。 func (h *Handler) ImportMaterials(c *gin.Context) { - orgID, ok := organizationID(c) - if !ok { - return - } + orgID, ok := organizationID(c) + if !ok { + return + } - file, err := c.FormFile("file") - if err != nil { - response.BadRequest(c, "请选择要导入的 xlsx 文件") - return - } - if file.Size > service.MaterialImportMaxBytes { - response.BadRequest(c, "文件超过 5MB 上限") - return - } + file, err := c.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要导入的 xlsx 文件") + return + } + if file.Size > service.MaterialImportMaxBytes { + response.BadRequest(c, "文件超过 5MB 上限") + return + } - source, err := file.Open() - if err != nil { - response.BadRequest(c, "文件读取失败") - return - } - defer source.Close() + source, err := file.Open() + if err != nil { + response.BadRequest(c, "文件读取失败") + return + } + defer source.Close() - operator := middleware.GetCurrentUsername(c) - if operator == "" { - operator = "系统" - } + operator := middleware.GetCurrentUsername(c) + if operator == "" { + operator = "系统" + } - result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source) - if err != nil { - if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) { - response.BadRequest(c, err.Error()) - return - } - response.InternalError(c, err.Error()) - return - } - if result.FailedRows > 0 { - response.ErrorWithData(c, http.StatusBadRequest, - "有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result) - return - } - response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result) + result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source) + if err != nil { + if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) { + response.BadRequest(c, err.Error()) + return + } + response.InternalError(c, err.Error()) + return + } + if result.FailedRows > 0 { + response.ErrorWithData(c, http.StatusBadRequest, + "有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result) + return + } + response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result) } ``` @@ -1843,26 +1935,26 @@ func (h *Handler) ImportMaterials(c *gin.Context) { 1. `Handler` 结构体加字段: ```go - materialImportSvc *service.MaterialImportService + materialImportSvc *service.MaterialImportService ``` 2. `NewHandler()` 加: ```go - materialImportSvc: service.NewMaterialImportService(), + materialImportSvc: service.NewMaterialImportService(), ``` 3. 在 `materials` 路由组之后加(**必须是独立前缀**): ```go - // 物料批量导入单独挂前缀:materials 组已有 GET /:id, - // gin 的路由树不允许同段既有静态前缀又有通配参数。 - materialImport := api.Group("/material-import") - materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) - { - materialImport.GET("/template", h.DownloadMaterialImportTemplate) - materialImport.POST("", h.ImportMaterials) - } + // 物料批量导入单独挂前缀:materials 组已有 GET /:id, + // gin 的路由树不允许同段既有静态前缀又有通配参数。 + materialImport := api.Group("/material-import") + materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware()) + { + materialImport.GET("/template", h.DownloadMaterialImportTemplate) + materialImport.POST("", h.ImportMaterials) + } ``` - [ ] **Step 6: 跑测试确认通过** @@ -1887,12 +1979,15 @@ git commit -m "feat: 物料导入与模板下载接口" ## Task 7: 前端 API 与工具 **Files:** + - Modify: `frontend/src/api/materials.js` - Create: `frontend/src/utils/exportCsv.js` - Test: `frontend/src/utils/exportCsv.test.js` **Interfaces:** + - Produces: `materialsApi.importFile(formData)`、`materialsApi.downloadImportTemplate()` + - Produces: `buildCsv(rows)`、`downloadCsv(fileName, csv)` - [ ] **Step 1: 写失败的测试** @@ -2017,11 +2112,14 @@ git commit -m "feat: 物料导入前端接口与 CSV 导出工具" ## Task 8: 导入结果弹窗组件 **Files:** + - Create: `frontend/src/components/business/MaterialImportResult.vue` - Test: `frontend/src/components/business/MaterialImportResult.test.js` **Interfaces:** + - Consumes: `buildCsv`、`downloadCsv` + - Produces: 组件 props `result`(`{ total_rows, success_rows, failed_rows, errors }`)、emits `close` - [ ] **Step 1: 写失败的测试** @@ -2243,10 +2341,12 @@ git commit -m "feat: 物料导入结果弹窗组件" ## Task 9: 物料页面接入 **Files:** + - Modify: `frontend/src/views/Materials.vue` - Test: `frontend/src/views/Materials.test.js`(追加) **Interfaces:** + - Consumes: `materialsApi.importFile`、`materialsApi.downloadImportTemplate`、`MaterialImportResult.vue`、`useSavingGuard` - [ ] **Step 1: 写失败的测试** @@ -2453,6 +2553,7 @@ git commit -m "feat: 物料页面接入批量导入与模板下载" ## Task 10: 文档与端到端验证 **Files:** + - Modify: `docs/api.md` - [ ] **Step 1: 补接口文档** @@ -2475,16 +2576,16 @@ docker compose down -v && docker compose up -d --build - [ ] **Step 3: 手工验证清单** -| 场景 | 期望 | -| --- | --- | -| 下载模板 | 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列 | -| 上传模板原样(无数据) | 提示没有数据行(0 行),不写记录或写 0 行记录 | -| 上传 2 行合法数据 | 201,列表出现 2 条物料,编码为 `小类前缀+序号`,日志页有 2 条 CREATE | -| 上传含空名称、错误大类、错误单位、无规则小类的文件 | 400,逐行错误行号与 Excel 一致,列表**没有**新增任何物料 | -| 上传同名同规格的重复文件两次 | 第二次仍成功(编码不同),确认首版不做重复检测 | -| 上传 .xls / .csv / 6MB 文件 | 400,提示格式或大小 | -| 非本公司分类名 | 400,提示分类不存在 | -| 导入过程中切公司 | 被保存互斥拦住 | +| 场景 | 期望 | +| ------------------------- | -------------------------------------------- | +| 下载模板 | 得到 xlsx,含「填写说明」「物料数据」两张表,表头 5 列 | +| 上传模板原样(无数据) | 提示没有数据行(0 行),不写记录或写 0 行记录 | +| 上传 2 行合法数据 | 201,列表出现 2 条物料,编码为 `小类前缀+序号`,日志页有 2 条 CREATE | +| 上传含空名称、错误大类、错误单位、无规则小类的文件 | 400,逐行错误行号与 Excel 一致,列表**没有**新增任何物料 | +| 上传同名同规格的重复文件两次 | 第二次仍成功(编码不同),确认首版不做重复检测 | +| 上传 .xls / .csv / 6MB 文件 | 400,提示格式或大小 | +| 非本公司分类名 | 400,提示分类不存在 | +| 导入过程中切公司 | 被保存互斥拦住 | - [ ] **Step 4: 确认导入记录落库** diff --git a/internal/model/material_import.go b/internal/model/material_import.go new file mode 100644 index 00000000..b1bfee1c --- /dev/null +++ b/internal/model/material_import.go @@ -0,0 +1,60 @@ +package model + +import "time" + +// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1 +// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。 +type MaterialImportRow struct { + Row int + Name string + Spec string + Unit string + MajorName string + MinorName string +} + +// Data 返回该行的原始内容,用于错误明细里的"原始数据"。 +func (r MaterialImportRow) Data() map[string]string { + return map[string]string{ + "name": r.Name, + "spec": r.Spec, + "unit": r.Unit, + "major": r.MajorName, + "minor": r.MinorName, + } +} + +// MaterialImportError 是一行的失败原因,Reason 可能包含多条,用中文分号连接。 +type MaterialImportError struct { + Row int `json:"row"` + Data map[string]string `json:"data"` + Reason string `json:"reason"` +} + +// MaterialImportResult 是一次导入的返回值。校验失败时 SuccessRows 为 0、 +// Errors 非空;成功时 Errors 为空数组(不是 null)。 +type MaterialImportResult struct { + TotalRows int `json:"total_rows"` + SuccessRows int `json:"success_rows"` + FailedRows int `json:"failed_rows"` + Errors []MaterialImportError `json:"errors"` +} + +// MaterialImportLog 是一次导入的持久化记录,成功或失败都会写一条。 +type MaterialImportLog struct { + ID int64 `json:"id"` + OrganizationID int64 `json:"organization_id"` + Operator string `json:"operator"` + FileName string `json:"file_name"` + TotalRows int `json:"total_rows"` + SuccessRows int `json:"success_rows"` + FailedRows int `json:"failed_rows"` + Status string `json:"status"` + FailureReason string `json:"failure_reason"` + CreatedAt time.Time `json:"created_at"` +} + +const ( + MaterialImportStatusSuccess = "success" + MaterialImportStatusFailed = "failed" +) diff --git a/scripts/upgrades/2026-10-10-material-bulk-import.sql b/scripts/upgrades/2026-10-10-material-bulk-import.sql new file mode 100644 index 00000000..a034455c --- /dev/null +++ b/scripts/upgrades/2026-10-10-material-bulk-import.sql @@ -0,0 +1,4 @@ +-- 物料批量导入:新增导入记录表。仅在已部署的库上执行一次;本地开发库走 init.sql 重建。 +SET NAMES utf8mb4; +CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-10-material-bulk-import','completed',NOW(),JSON_OBJECT('version','2026-10-10-material-bulk-import','source','upgrade'));