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
+
+