diff --git a/docs/superpowers/plans/2026-10-10-material-bulk-import.md b/docs/superpowers/plans/2026-10-10-material-bulk-import.md new file mode 100644 index 00000000..43fa590f --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-material-bulk-import.md @@ -0,0 +1,2517 @@ +# 物料批量导入实现计划 + +> **For agentic workers:** 使用 `superpowers:executing-plans` 按任务执行;步骤使用复选框(`- [ ]`)追踪。 + +**Goal:** 物料页面提供「下载模板」与「批量导入」两个入口,用户上传填好的 .xlsx 后,后端逐行校验并在同一个请求内事务入库,失败时整批不入库并返回逐行错误明细。 + +**Architecture:** 新增 `internal/service/material_import*.go` 负责 xlsx 解析、模板生成与逐行校验;`internal/repository/material.go` 新增批量入库方法(单事务内锁编码规则、分配编码、分批多值插入);`internal/handler/material_import.go` 暴露两个 HTTP 接口。每次导入(无论成败)写一条 `material_import_logs` 记录;逐行错误只在响应里返回,不落库。 + +**Tech Stack:** Go 1.21、Gin、MySQL 8.0、`github.com/xuri/excelize/v2 v2.8.1`、sqlmock、Vue 3、Pinia、Element Plus、Vitest。 + +**Spec:** 本计划正文「设计约定」一节即本次交付的完整规格(源自需求方给出的《物料批量导入功能设计(最终版)》及后续澄清)。用户故事 `docs/MMS-用户故事-v2.3.md` 的 BAT-001、5.8.1 为背景需求。 + +## Global Constraints + +- Excel 库固定 `github.com/xuri/excelize/v2 v2.8.1`:**不得升级到 v2.9+**,v2.9 起要求 Go ≥1.23,而 `Dockerfile` 使用 `golang:1.21-alpine`(已实测 v2.8.1 可编译、可 `OpenReader`/`WriteToBuffer`)。 +- 文件限制:仅 `.xlsx`,≤ 5 MB,数据区 ≤ 20,000 行(不含表头)。 +- 首版仅支持**新增**物料,不做更新、不做分类/单位的自动创建。 +- 物料编码由系统按小类编码规则生成,**不接受 Excel 中的编码列**;小类未配置启用的编码规则时该行报错。 +- 任意一行校验失败 → 整批不入库,返回全部逐行错误。 +- 所有接口必须挂 `middleware.AuthMiddleware(), middleware.OrganizationMiddleware()`,公司取服务端上下文,忽略请求体伪造值。 +- 注释、提交信息、用户可见文案全部用中文(与仓库既有风格一致)。 +- 提交只包含本功能涉及的文件;工作区已有的「默认单位 pcs→个」在途改动(`frontend/src/views/materialForm.js`、`internal/service/material.go` 等 7 个文件)**不要一起提交**。 + +--- + +## 设计约定 + +### 1. 流程 + +下载模板 → 填写 → 上传 → 后端解析 → 逐行校验 → **全部通过即在同一个请求内事务入库** → 返回结果。 + +**已确认去掉「预览确认」环节**:不再有暂存任务 ID、不再有确认接口,也不存在"确认时重新校验"。校验失败时整批不入库,错误明细随响应返回。 + +### 2. 模板 + +两个工作表: + +- `填写说明`:字段名、是否必填、示例、填写规则。 +- `物料数据`:第 1 行表头,数据从第 2 行开始。 + +表头固定 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时按「去空格 + 去尾部 `*`」比对表头,不匹配即报「模板格式不正确,请重新下载最新模板」。 + +分类**按名称填写**,后端按「大类名 + 小类名」组合在本公司内匹配(小类名在本公司内会重名,如「其他」在 A07/B07/C03/D04/F05 都叫「其他」,因此必须两级组合)。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。 + +### 3. 接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/material-import/template` | 下载 xlsx 模板,返回文件流 | +| POST | `/api/material-import` | `multipart/form-data`,字段名 `file`,上传即入库 | + +**路由必须挂在独立前缀 `/material-import`**:`materials` 组下已有 `GET /:id`,gin 的路由树不允许同一段既有静态前缀又有 `:id` 通配,把 `/materials/import/...` 加进该组会直接 panic。 + +成功响应 `201 Created`: + +```json +{ + "code": 0, + "message": "成功导入 200 条物料", + "data": { "total_rows": 200, "success_rows": 200, "failed_rows": 0, "errors": [] } +} +``` + +校验失败 `400 Bad Request`(HTTP 状态 400,但 `data` 带完整明细): + +```json +{ + "code": 400, + "message": "有 3 行校验未通过,本次未导入任何数据", + "data": { + "total_rows": 200, "success_rows": 0, "failed_rows": 3, + "errors": [ + { "row": 3, "data": { "name": "", "spec": "M8x30", "unit": "个", "major": "机械结构类", "minor": "标准件" }, "reason": "物料名称不能为空" } + ] + } +} +``` + +`row` 是 Excel 中的真实行号(从 1 开始,含表头行),用户可直接对着 Excel 定位。 + +### 4. 校验规则(逐行) + +| 列 | 规则 | +| --- | --- | +| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 | +| 规格 | 选填,≤ 200 字 | +| 单位 | 选填;为空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 | +| 大类 | 必填,必须是当前公司已启用的顶层分类名称 | +| 小类 | 必填,必须是该大类下已启用的小类名称 | +| 编码规则 | 该小类必须存在 `status='active'` 的 `code_rules` 记录 | + +同一行可产生多条原因,用中文分号连接。 + +### 5. 编码生成与并发 + +- 入库事务内按 `category_id` **升序**依次 `SELECT ... FOR UPDATE` 锁定 `code_rules` 行(升序加锁避免多事务互相等待造成死锁)。 +- 每个小类一次性取出已占用编码集合(`SELECT material_code FROM materials WHERE organization_id=? AND material_code LIKE ?`),在内存里顺序分配,跳过已占用号(库中可能存在手工录入、落在号段内的编码)。 +- 分配完成后 `UPDATE code_rules SET next_sequence = ?`。 +- 校验阶段不生成编码,预览/响应里也不承诺最终编码。 + +### 6. 事务与记录 + +- 整批入库在**一个事务**内:分配编码 → 分批多值 INSERT 物料(每批 500 行)→ 回填 ID → 批量写 `material_logs`(每条物料一条 `CREATE` 审计记录,与单条新增保持一致)。 +- 任一行插入失败则整体回滚。 +- 每次导入(成功或失败)都写一条 `material_import_logs`:操作人、公司、时间、文件名、总行数、成功数、失败数、状态、失败原因摘要。**逐行错误明细不落库**,只在响应里返回。 + +### 7. 权限 + +沿用物料新增的权限口径:仅要求登录 + 当前公司可访问(与 `POST /api/materials` 相同,不额外要求 `super_admin`)。公司取自 `X-Organization-ID` 经中间件校验后的上下文。 + +### 8. 防止重复提交 + +去掉预览与暂存后没有服务端幂等键(设计第 7 点原文的「任务 ID 作为幂等键」随预览一起取消),靠两道前端约束: + +- 保存互斥:整个上传流程包在 `runWithSaving` 里,飞行期间禁止切换公司; +- 入口状态:`importing` 标志守住 `triggerImport`,上传期间再点不会重新选文件;`handleImportFile` 在发起请求前就把 input 的 `value` 清空,避免同一个文件连续触发两次 `change`。 + +后端不做文件级去重:同一份文件重复上传会真的导入两遍(编码不同)。这是首版明确接受的取舍,见文末「已知取舍」。 + +--- + +## 文件结构 + +**新建:** + +| 文件 | 职责 | +| --- | --- | +| `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` | 补接口文档 | + +**测试:** + +| 文件 | 覆盖 | +| --- | --- | +| `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 增加建表语句** + +在 `scripts/init.sql` 的 `code_rules` 那行之后插入: + +```sql +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; +``` + +- [ ] **Step 2: 写增量升级脚本** + +创建 `scripts/upgrades/2026-10-10-material-bulk-import.sql`,内容与上一步完全相同的 `CREATE TABLE material_import_logs`,并在末尾追加迁移登记: + +```sql +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')); +``` + +- [ ] **Step 3: 写模型** + +创建 `internal/model/material_import.go`: + +```go +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" +) +``` + +- [ ] **Step 4: 编译验证** + +Run: `go build ./...` +Expected: 通过。 + +- [ ] **Step 5: 重建本地数据库(旧库无增量升级路径)** + +Run: `docker compose down -v && docker compose up -d db` +说明:本地库只认 `init.sql`,不执行 `upgrades/` 脚本;这一步之后表才会存在。 + +- [ ] **Step 6: Commit** + +```bash +git add scripts/init.sql scripts/upgrades/2026-10-10-material-bulk-import.sql internal/model/material_import.go +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** + +```bash +go get github.com/xuri/excelize/v2@v2.8.1 +``` + +Run: `grep excelize go.mod` +Expected: `github.com/xuri/excelize/v2 v2.8.1`(**不可为 v2.9+**) + +- [ ] **Step 2: 写失败的测试** + +创建 `internal/service/material_import_excel_test.go`: + +```go +package service + +import ( + "bytes" + "errors" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) { + 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]) + } +} + +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) + } +} + +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) + } + + _, err = ParseMaterialImportFile(bytes.NewReader(buf.Bytes())) + if !errors.Is(err, ErrImportTemplateInvalid) { + t.Fatalf("err = %v, want ErrImportTemplateInvalid", err) + } +} + +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) + + _, 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() + + 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 字节流。 +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() +} +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `go test ./internal/service/ -run TestParseMaterialImportFile -v` +Expected: FAIL / 编译错误 `undefined: ParseMaterialImportFile` + +- [ ] **Step 4: 实现模板生成与解析** + +创建 `internal/service/material_import_excel.go`: + +```go +package service + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/wmms/wmms/internal/model" + "github.com/xuri/excelize/v2" +) + +const ( + // MaterialImportSheetData 是填写数据的表名,「物料数据」。 + MaterialImportSheetData = "物料数据" + // MaterialImportSheetGuide 是填写说明的表名。 + MaterialImportSheetGuide = "填写说明" + // MaterialImportMaxRows 是数据区最大行数(不含表头)。 + MaterialImportMaxRows = 20000 + // MaterialImportMaxBytes 是上传文件大小上限。 + MaterialImportMaxBytes = 5 << 20 +) + +var ( + // ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。 + ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板") + // ErrImportTooManyRows 表示数据行数超出上限。 + ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows) +) + +// materialImportHeaders 是「物料数据」表的表头,顺序即列序。 +var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"} + +// BuildMaterialImportTemplate 生成导入模板:一张填写说明表 + 一张空白数据表。 +func BuildMaterialImportTemplate() ([]byte, error) { + 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) + + 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) + + 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 +} + +// 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() + + 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 + } + + 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) + } + + 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 +} + +func materialImportCell(row []string, index int) string { + if index >= len(row) { + return "" + } + return strings.TrimSpace(row[index]) +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `go test ./internal/service/ -run 'MaterialImport(File|Template)' -v` +Expected: PASS(全部 4 个函数) + +- [ ] **Step 6: Commit** + +```bash +git add go.mod go.sum internal/service/material_import_excel.go internal/service/material_import_excel_test.go +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: 写失败的测试** + +创建 `internal/repository/material_import_test.go`: + +```go +package repository + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "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() + } +} + +func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) { + 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")) + + 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.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) + } +} + +func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) { + // 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)) + + 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 +} + +func TestMaterialLogRepoCreateBatchTx(t *testing.T) { + mock, cleanup := newImportMock(t) + defer cleanup() + + 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) + } +} +``` + +**注意:** 仓库里 `insertMaterialStmt` 定义在 `internal/service/material_test.go`(service 包),repository 包内没有同名常量。批量插入是**多值**语句,所以要在 `internal/repository/material_import_test.go` 里按参与行数拼出期望文本。测试里用到的是两行一批: + +```go +const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` +``` + +行数更多的场景(如 501 行那一条)用 `mock.ExpectExec("INSERT INTO materials")` 松匹配即可,不必拼 501 组占位符。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `go test ./internal/repository/ -run 'ImportTx|CreateBatchTx' -v` +Expected: FAIL / 编译错误 `undefined: ImportTx` + +- [ ] **Step 3: 实现批量入库** + +在 `internal/repository/material.go` 末尾追加(**不要修改已有函数**): + +```go +// materialImportBatchSize 是批量插入物料时的每批行数。编码已在内存里按小类 +// 顺序分配好,这里只负责把 INSERT 折叠成分批的多值语句,避免两万行逐条往返。 +const materialImportBatchSize = 500 + +// ImportTx 在调用方事务内为整批物料分配编码并插入,任意一行失败都由调用方回滚。 +// 编码按小类分组生成:组内先对 code_rules 行加锁(按 category_id 升序加锁, +// 避免两个导入事务以不同顺序持锁而死锁),再跳过库中已占用的号段。 +func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error { + 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 }) + + // 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 + } + } + + // 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 取出本公司该前缀下已存在的全部物料编码,供内存判重。 +// 前缀来自公司自己的编码规则配置,若它含 % 或 _ 只会让 LIKE 多匹配到无关编码, +// 而那些编码的格式与本规则生成的编码不同、永远不会被 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() + + 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) +} + +// 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 +} + +// 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...) + + 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 +} +``` + +需要在 `material.go` 的 import 块补上 `"sort"`(`fmt`、`strings`、`sql`、`model` 已在)。 + +- [ ] **Step 4: 实现批量写日志** + +在 `internal/repository/material_log.go` 末尾追加: + +```go +// 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 +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `go test ./internal/repository/ -run 'ImportTx|CreateBatchTx' -v` +Expected: PASS(4 个测试) + +- [ ] **Step 6: 跑全仓测试确认没弄坏既有逻辑** + +Run: `go test ./... 2>&1 | grep -v "^ok\|no test files"` +Expected: 无 FAIL + +- [ ] **Step 7: Commit** + +```bash +git add internal/repository/material.go internal/repository/material_log.go internal/repository/material_import_test.go +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: 写失败的测试** + +在 `internal/repository/material_import_test.go` 末尾追加: + +```go +func TestMaterialImportLogRepoCreate(t *testing.T) { + 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)) + + 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) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `go test ./internal/repository/ -run TestMaterialImportLogRepoCreate -v` +Expected: FAIL / 编译错误 `undefined: NewMaterialImportLogRepo` + +- [ ] **Step 3: 实现** + +创建 `internal/repository/material_import.go`: + +```go +package repository + +import ( + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +type MaterialImportLogRepo struct{} + +func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} } + +// 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 +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `go test ./internal/repository/ -run TestMaterialImportLogRepoCreate -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/repository/material_import.go internal/repository/material_import_test.go +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` + +**说明:** 校验阶段需要"某小类是否配置了启用规则",现有 `MasterDataRepository.FindActiveCodeRule`(`master_data.go:142`)即可用,无需新增。 + +- [ ] **Step 1: 写失败的测试** + +创建 `internal/service/material_import_test.go`: + +```go +package service + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "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` +const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code` +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() + } +} + +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"})) + } +} + +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)) +} + +func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + 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) + } +} + +func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + 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) + } +} + +func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) { + mock, cleanup := newImportServiceMock(t) + defer cleanup() + + 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) + } +} + +func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) { + 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, "") + + 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() + + 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) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `go test ./internal/service/ -run TestMaterialImportService -v` +Expected: FAIL / 编译错误 `undefined: NewMaterialImportService` + +- [ ] **Step 3: 实现** + +创建 `internal/service/material_import.go`: + +```go +package service + +import ( + "fmt" + "io" + "strings" + + "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 +} + +func NewMaterialImportService() *MaterialImportService { + return &MaterialImportService{ + materialRepo: repository.NewMaterialRepo(), + masterRepo: repository.NewMasterDataRepository(), + logRepo: repository.NewMaterialLogRepo(), + importRepo: repository.NewMaterialImportLogRepo(), + } +} + +// Import 处理一次上传。返回值约定: +// - 校验未通过:返回 result(含逐行错误)与 nil error,前端按明细展示; +// - 模板或文件不合法:返回 nil 与错误,前端提示原因; +// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。 +// +// 三种情况都会写一条导入记录。 +func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) { + 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 + } + + 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 + } + + 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 + } + + 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) + + 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 字") + } + + 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)) + } + } + + // 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配, + // 因为像「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 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, + }) + } + + 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() }() + + 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() +} + +// 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, + }) +} + +// summarizeImportErrors 生成给记录表用的失败原因摘要:前 5 行行号 + 总数。 +func summarizeImportErrors(importErrors []model.MaterialImportError) string { + rows := make([]string, 0, 5) + for i, item := range importErrors { + if i == 5 { + break + } + rows = append(rows, fmt.Sprintf("%d", item.Row)) + } + summary := fmt.Sprintf("共 %d 行校验未通过(行号:%s", len(importErrors), strings.Join(rows, "、")) + if len(importErrors) > 5 { + summary += " 等" + } + return summary + ")" +} + +func minorCategoryKey(majorID int64, minorName string) string { + return fmt.Sprintf("%d\x00%s", majorID, minorName) +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `go test ./internal/service/ -run TestMaterialImportService -v` +Expected: PASS(5 个测试) + +- [ ] **Step 5: 跑全仓测试** + +Run: `go test ./... 2>&1 | grep -v "^ok\|no test files"` +Expected: 无 FAIL + +- [ ] **Step 6: Commit** + +```bash +git add internal/service/material_import.go internal/service/material_import_test.go +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: 加通用错误响应** + +在 `pkg/response/response.go` 的 `Error` 之后追加: + +```go +// ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误 +// 一起带回去,不能只给一句话。 +func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) { + c.JSON(httpStatus, Response{ + Code: httpStatus, + Message: message, + Data: data, + }) +} +``` + +- [ ] **Step 2: 写失败的测试** + +创建 `internal/handler/material_import_test.go`: + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "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) + + 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) + } +} +``` + +并在 `internal/handler/routes_test.go` 的 `TestPhaseOneAndLegacyRoutesAreRegistered` 里,往 `want` 切片的 `"POST /api/logs"` 那一行之后追加(该切片每项是 `"方法 路径"` 字符串): + +```go + "GET /api/material-import/template", "POST /api/material-import", +``` + +同文件的 `TestOrganizationBusinessRoutesRequireHeaderBeforeHandler` 会自动遍历到这两条新路由(它们不在豁免前缀里),断言缺少 `X-Organization-ID` 时返回 400,因此只要路由挂上了 `OrganizationMiddleware()` 就会被它覆盖,无需另写权限用例。 + +- [ ] **Step 3: 跑测试确认失败** + +Run: `go test ./internal/handler/ -run 'ErrorWithData|Routes' -v` +Expected: FAIL(`ErrorWithData` 未定义 / 路由缺失) + +- [ ] **Step 4: 实现 handler** + +创建 `internal/handler/material_import.go`: + +```go +package handler + +import ( + "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" +) + +// 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) +} + +// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。 +// 校验失败返回 400 且 data 里带逐行错误明细。 +func (h *Handler) ImportMaterials(c *gin.Context) { + 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 + } + + source, err := file.Open() + if err != nil { + response.BadRequest(c, "文件读取失败") + return + } + defer source.Close() + + 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) +} +``` + +- [ ] **Step 5: 注册路由与服务** + +`internal/handler/api.go`: + +1. `Handler` 结构体加字段: + +```go + materialImportSvc *service.MaterialImportService +``` + +2. `NewHandler()` 加: + +```go + 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) + } +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `go test ./internal/handler/ -v -run 'ErrorWithData|Routes'` +Expected: PASS + +- [ ] **Step 7: 起服务验证路由不 panic** + +Run: `go build -o server.exe ./cmd/server && echo BUILD_OK` +Expected: `BUILD_OK`(gin 路由冲突会在注册时 panic,构建通过后由下一步的路由测试兜住) + +- [ ] **Step 8: Commit** + +```bash +git add internal/handler/material_import.go internal/handler/material_import_test.go internal/handler/api.go internal/handler/routes_test.go pkg/response/response.go +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: 写失败的测试** + +创建 `frontend/src/utils/exportCsv.test.js`: + +```js +import { describe, expect, test } from 'vitest' +import { buildCsv } from './exportCsv' + +describe('buildCsv', () => { + test('用逗号分隔并在行尾换行', () => { + const csv = buildCsv([ + ['行号', '失败原因'], + [3, '物料名称不能为空'] + ]) + expect(csv).toBe('行号,失败原因\r\n3,物料名称不能为空\r\n') + }) + + test('包含逗号、引号、换行的字段用双引号包裹并转义', () => { + const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']]) + expect(csv).toBe('规格,备注\r\n"M8,30","含""引号"""\r\n"两\n行",\r\n') + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd frontend && npx vitest run src/utils/exportCsv.test.js` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 实现** + +创建 `frontend/src/utils/exportCsv.js`: + +```js +// 生成 CSV 文本。带 UTF-8 BOM,Excel 打开中文才不会乱码;字段里的 +// 逗号、引号、换行按 RFC 4180 用双引号包裹并转义。 +export function buildCsv(rows) { + const lines = rows.map(row => row.map(escapeCsvCell).join(',')) + return '' + lines.map(line => line + '\r\n').join('') +} + +function escapeCsvCell(value) { + const text = value === null || value === undefined ? '' : String(value) + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"` + } + return text +} + +// 触发浏览器下载。用 Blob + a[download],不依赖任何第三方库。 +export function downloadCsv(fileName, csv) { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd frontend && npx vitest run src/utils/exportCsv.test.js` +Expected: PASS + +- [ ] **Step 5: 加 API** + +在 `frontend/src/api/materials.js` 的 `materialsApi` 对象里追加两个方法(放在 `uploadImage` 之后): + +```js + importFile(formData) { + // silent:校验失败的明细要自己弹窗展示,不让拦截器先弹一句笼统提示 + return request.post('/material-import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + silent: true + }) + }, + downloadImportTemplate() { + // 下载走原生 fetch:响应是文件流,axios 拦截器只认 JSON 的 code 字段 + return fetch('/api/material-import/template', { + headers: downloadHeaders() + }) + } +``` + +并在文件顶部补上取头信息的辅助: + +```js +import { getOrganizationId } from '../utils/organizationContext' + +// 下载接口绕开 axios,得自己带令牌与当前公司,否则 401 / 400。 +// 令牌沿用拦截器同款读法(localStorage),避免在 API 模块里依赖 Pinia 激活时序。 +function downloadHeaders() { + const token = localStorage.getItem('wmms_token') + const organizationId = getOrganizationId() + return { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {}) + } +} +``` + +- [ ] **Step 6: 跑前端测试** + +Run: `cd frontend && npx vitest run src/api` +Expected: 无新增失败 + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/api/materials.js frontend/src/utils/exportCsv.js frontend/src/utils/exportCsv.test.js +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: 写失败的测试** + +创建 `frontend/src/components/business/MaterialImportResult.test.js`: + +```js +import { mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { describe, expect, test, vi } from 'vitest' +import MaterialImportResult from './MaterialImportResult.vue' + +const failedResult = { + total_rows: 3, + success_rows: 0, + failed_rows: 2, + errors: [ + { row: 2, data: { name: '', spec: 'M8x30', unit: '个', major: '机械结构类', minor: '标准件' }, reason: '物料名称不能为空' }, + { row: 4, data: { name: '螺栓', spec: '', unit: '吨', major: '机械结构类', minor: '标准件' }, reason: '单位「吨」不存在或已停用' } + ] +} + +function mountResult(result) { + return mount(MaterialImportResult, { + props: { result }, + global: { plugins: [ElementPlus], stubs: { teleport: true } } + }) +} + +describe('MaterialImportResult', () => { + test('失败时展示行数摘要与每行错误', () => { + const wrapper = mountResult(failedResult) + + expect(wrapper.text()).toContain('共 3 行') + expect(wrapper.text()).toContain('2 行未通过') + const rows = wrapper.findAll('tbody tr') + expect(rows.length).toBe(2) + expect(rows[0].text()).toContain('2') + expect(rows[0].text()).toContain('物料名称不能为空') + wrapper.unmount() + }) + + test('导出错误明细时把行号与原因写进 CSV', async () => { + const download = vi.fn() + const wrapper = mount(MaterialImportResult, { + props: { result: failedResult }, + global: { + plugins: [ElementPlus], + stubs: { teleport: true }, + provide: { downloadCsv: download } + } + }) + + await wrapper.find('[data-test="export-errors"]').trigger('click') + + expect(download).toHaveBeenCalledTimes(1) + const [fileName, csv] = download.mock.calls[0] + expect(fileName).toContain('导入错误明细') + expect(csv).toContain('物料名称不能为空') + expect(csv).toContain('行号') + wrapper.unmount() + }) + + test('全部成功时只展示成功摘要,不显示错误表格', () => { + const wrapper = mountResult({ total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }) + + expect(wrapper.text()).toContain('成功导入 2 条') + expect(wrapper.find('[data-test="error-table"]').exists()).toBe(false) + wrapper.unmount() + }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd frontend && npx vitest run src/components/business/MaterialImportResult.test.js` +Expected: FAIL(组件不存在) + +- [ ] **Step 3: 实现组件** + +创建 `frontend/src/components/business/MaterialImportResult.vue`: + +```vue + + + + + +``` + +**注意:** `BasePagination` 的 props 是 `currentPage` / `pageSize` / `total`,emit 的是 `change`(`frontend/src/components/business/BasePagination.vue:18-24`),上面用法与之一致。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd frontend && npx vitest run src/components/business/MaterialImportResult.test.js` +Expected: PASS(3 个测试) + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/business/MaterialImportResult.vue frontend/src/components/business/MaterialImportResult.test.js +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: 写失败的测试** + +在 `frontend/src/views/Materials.test.js` 末尾追加(沿用文件里已有的 `mountMaterials()`、mock 结构): + +```js + test('批量导入成功后提示成功并刷新列表', async () => { + materialsApi.importFile.mockResolvedValue({ + data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] } + }) + + const wrapper = mountMaterials() + await flushPromises() + + await setupState(wrapper).handleImportFile({ + target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' } + }) + await flushPromises() + + expect(materialsApi.importFile).toHaveBeenCalled() + expect(materialsStore.fetchMaterials).toHaveBeenCalled() + expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料') + wrapper.unmount() + }) + + test('批量导入失败时展示错误明细且不刷新列表', async () => { + const error = new Error('校验未通过') + error.response = { + data: { + code: 400, + message: '有 1 行校验未通过,本次未导入任何数据', + data: { + total_rows: 1, success_rows: 0, failed_rows: 1, + errors: [{ row: 2, data: { name: '', spec: '', unit: '', major: '', minor: '' }, reason: '物料名称不能为空' }] + } + } + } + materialsApi.importFile.mockRejectedValue(error) + + const wrapper = mountMaterials() + await flushPromises() + + await setupState(wrapper).handleImportFile({ + target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' } + }) + await flushPromises() + + expect(setupState(wrapper).importResult.failed_rows).toBe(1) + expect(materialsStore.fetchMaterials).not.toHaveBeenCalled() + wrapper.unmount() + }) +``` + +**注意:** `Materials.test.js` 目前 mock 的是 `vi.mock('../stores/materials')` 与 `vi.mock('../../api/masterData')`,需要在顶部补 `vi.mock('../../api/materials')`,导出 `materialsApi`(含 `importFile`/`downloadImportTemplate` 两个 `vi.fn()`);`ElMessage` 的 spy 若尚未建立,按文件里既有的 `vi.mock('element-plus')` 方式补齐。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd frontend && npx vitest run src/views/Materials.test.js` +Expected: 新增的两个用例 FAIL + +- [ ] **Step 3: 实现页面接入** + +`frontend/src/views/Materials.vue` 改动: + +1. 顶部按钮区(第 3-6 行)加两个按钮和一个隐藏的文件输入: + +```vue + +``` + +2. 在页面底部加导入结果弹窗: + +```vue + + + +``` + +3. `script setup` 里补充: + +```js +import MaterialImportResult from '../components/business/MaterialImportResult.vue' +import { materialsApi } from '../api/materials' + +const importInput = ref(null) +const importDialogVisible = ref(false) +const importResult = ref(null) +const importing = ref(false) + +function triggerImport() { + if (importing.value) return + importInput.value?.click() +} + +async function handleImportFile(event) { + const file = event.target.files?.[0] + // 清空 input,否则连续选同一个文件不会再触发 change + event.target.value = '' + if (!file) return + if (!file.name.toLowerCase().endsWith('.xlsx')) { + ElMessage.warning('只支持 .xlsx 文件') + return + } + if (file.size > 5 * 1024 * 1024) { + ElMessage.warning('文件不能超过 5MB') + return + } + + const formData = new FormData() + formData.append('file', file) + + importing.value = true + try { + // 导入是写请求:包进保存互斥,飞行期间禁止切换公司 + const res = await runWithSaving(() => materialsApi.importFile(formData)) + const result = res.data + await materialsStore.fetchMaterials(1, '', 'all') + searchKeyword.value = '' + ElMessage.success(`成功导入 ${result.success_rows} 条物料`) + } catch (e) { + // 校验失败时响应里带逐行明细,弹窗展示;其余错误交给拦截器之外的兜底提示 + const detail = e?.response?.data?.data + if (detail && Array.isArray(detail.errors)) { + importResult.value = detail + importDialogVisible.value = true + } else if (!e?.response) { + console.error('批量导入失败:', e) + ElMessage.error(errorText(e, '批量导入失败')) + } + } finally { + importing.value = false + } +} + +function handleImportDialogClosed() { + importResult.value = null +} + +async function handleDownloadTemplate() { + try { + const res = await runWithSaving(() => materialsApi.downloadImportTemplate()) + if (!res.ok) { + ElMessage.error('模板下载失败') + return + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = '物料导入模板.xlsx' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } catch (e) { + console.error('下载模板失败:', e) + ElMessage.error('模板下载失败') + } +} +``` + +4. `